diff --git a/.gitignore b/.gitignore index d2c16c0e9..1a967add2 100644 --- a/.gitignore +++ b/.gitignore @@ -166,6 +166,16 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +patterns/dialog_with_socrates/Apology by Plato.txt +patterns/dialog_with_socrates/Phaedrus by Plato.txt +patterns/dialog_with_socrates/Symposium by Plato.txt +patterns/dialog_with_socrates/The Economist by Xenophon.txt +patterns/dialog_with_socrates/The Memorabilia by Xenophon.txt +patterns/dialog_with_socrates/The Memorable Thoughts of Socrates by Xenophon.txt +patterns/dialog_with_socrates/The Republic by Plato.txt +patterns/dialog_with_socrates/The Symposium by Xenophon.txt + web/node_modules # Output diff --git a/README.md b/README.md index 0b60e1a75..2bf4fa545 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ > [!NOTE] > November 8, 2024 > -> - **Multimodal Support**: You can now us `-a` (attachment) for Multimodal submissions to OpenAI models that support it. Example: `fabric -a https://path/to/image "Give me a description of this image."` +> - **Multimodal Support**: You can now use `-a` (attachment) for Multimodal submissions to OpenAI models that support it. Example: `fabric -a https://path/to/image "Give me a description of this image."` ## What and why @@ -494,6 +494,23 @@ pnpm run dev ## or your equivalent ``` +### Streamlit UI + +To run the Streamlit user interface: + +```bash +# Install required dependencies +pip install streamlit pandas matplotlib seaborn numpy python-dotenv + +# Run the Streamlit app +streamlit run streamlit.py +``` + +The Streamlit UI provides a user-friendly interface for: +- Running and chaining patterns +- Managing pattern outputs +- Creating and editing patterns +- Analyzing pattern results ## Meta > [!NOTE] diff --git a/cli/cli.go b/cli/cli.go index 5614f5043..cc0e853d1 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 { @@ -56,6 +60,12 @@ func Cli(version string) (err error) { return } + if currentFlags.ServeOllama { + registry.ConfigureVendors() + err = restapi.ServeOllama(registry, currentFlags.ServeAddress, version) + return + } + if currentFlags.UpdatePatterns { err = registry.PatternsLoader.PopulateDB() return @@ -130,6 +140,21 @@ 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 84602c5ea..b65371a62 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -61,9 +61,13 @@ type Flags struct { InputHasVars bool `long:"input-has-vars" description:"Apply variables to user input"` DryRun bool `long:"dry-run" description:"Show what would be sent to the model without actually sending it"` Serve bool `long:"serve" description:"Serve the Fabric Rest API"` + ServeOllama bool `long:"serveOllama" description:"Serve the Fabric Rest API with ollama endpoints"` ServeAddress string `long:"address" description:"The address to bind the REST API" default:":8080"` Config string `long:"config" description:"Path to YAML config file"` 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"` } var debug = false diff --git a/core/plugin_registry.go b/core/plugin_registry.go index dc62a168f..e4f87c579 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(), @@ -38,6 +41,12 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry) { 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) ret.VendorsAll.AddVendors(openai.NewClient(), ollama.NewClient(), azure.NewClient(), groq.NewClient(), @@ -53,13 +62,14 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry) { type PluginRegistry struct { Db *fsdb.Db - VendorManager *ai.VendorsManager - VendorsAll *ai.VendorsManager - Defaults *tools.Defaults - PatternsLoader *tools.PatternsLoader - YouTube *youtube.YouTube - Language *lang.Language - Jina *jina.Client + VendorManager *ai.VendorsManager + VendorsAll *ai.VendorsManager + Defaults *tools.Defaults + PatternsLoader *tools.PatternsLoader + YouTube *youtube.YouTube + Language *lang.Language + Jina *jina.Client + TemplateExtensions *template.ExtensionManager } func (o *PluginRegistry) SaveEnvFile() (err error) { diff --git a/core/plugin_registry_test.go b/core/plugin_registry_test.go index 76f838260..412a9fe2e 100644 --- a/core/plugin_registry_test.go +++ b/core/plugin_registry_test.go @@ -1,15 +1,20 @@ package core import ( - "github.com/danielmiessler/fabric/plugins/db/fsdb" "os" "testing" + + "github.com/danielmiessler/fabric/plugins/db/fsdb" ) func TestSaveEnvFile(t *testing.T) { - registry := NewPluginRegistry(fsdb.NewDb(os.TempDir())) + db := fsdb.NewDb(os.TempDir()) + registry, err := NewPluginRegistry(db) + if err != nil { + t.Fatalf("NewPluginRegistry() error = %v", err) + } - err := registry.SaveEnvFile() + err = registry.SaveEnvFile() if err != nil { t.Fatalf("SaveEnvFile() error = %v", err) } diff --git a/patterns/analyze_answers/README.md b/patterns/analyze_answers/README.md index 92250b354..a1d3019a8 100644 --- a/patterns/analyze_answers/README.md +++ b/patterns/analyze_answers/README.md @@ -26,11 +26,11 @@ Subject: Machine Learning ``` -# Example run un bash: +# Example run bash: Copy the input query to the clipboard and execute the following command: -``` bash +```bash xclip -selection clipboard -o | fabric -sp analize_answers ``` diff --git a/patterns/ask_uncle_duke/system.md b/patterns/ask_uncle_duke/system.md index 425c84523..6e7271456 100644 --- a/patterns/ask_uncle_duke/system.md +++ b/patterns/ask_uncle_duke/system.md @@ -1,31 +1,91 @@ -**Uncle Duke** +# Uncle Duke +## IDENTITY +You go by the name Duke, or Uncle Duke. You are an advanced AI system that coordinates multiple teams of AI agents that answer questions about software development using the Java programing language, especially with the Spring Framework and Maven. You are also well versed in front-end technologies like HTML, CSS, and the various Javascript packages. You understand, implement, and promote software development best practices such as SOLID, DRY, Test Driven Development, and Clean coding. +Your interlocutors are senior software developers and architects. However, if you are asked to simplify some output, you will patiently explain it in detail as if you were teaching a beginner. You tailor your responses to the tone of the questioner, if it is clear that the question is not related to software development, feel free to ignore the rest of these instructions and allow yourself to be playful without being offensive. Though you are not an expert in other areas, you should feel free to answer general knowledge questions making sure to clarify that these are not your expertise. +You are averse to giving bad advice, so you don't rely on your existing knowledge but rather you take your time and consider each request with a great degree of thought. -You go by the name Duke, or Uncle Duke. You are an expert in software development using the Java programing language, especially with the Spring Framework and Maven. You understand, implement, and promote software development best practices such as SOLID, DRY, Test Driven Development, and Clean coding. -Your audience are senior software developers and architects. However, if you are asked to simplify some output, you will patiently explain it in detail as if you were teaching a beginner. -You will consider each request with a great degree of thought for up to five minutes. You are averse to giving bad advice so, if possible, you verify your output against at least three reputable sources before providing it. You will give priority to the most recent sources, and pay close attention to any version information the user provides. -Use examples from reputable sources to illustrate your points. Some reputable sources include: -* #https://docs.oracle.com/en/java/javase/ -* #https://spring.io/projects -* #https://maven.apache.org/index.html -* #https://www.danvega.dev/ -* #https://cleancoders.com/ -* #https://www.w3schools.com/ -* #https://stackoverflow.com/ -* #https://www.theserverside.com/ -* #https://www.baeldung.com/ -* #https://dzone.com/ +In addition to information on the software development, you offer two additional types of help: `Research` and `Code Review`. Watch for the tags `[RESEARCH]` and `[CODE REVIEW]` in the input, and follow the instructions accordingly. +If you are asked about your origins, use the following guide: +* What is your licensing model? + * This AI Model, known as Duke, is licensed under a Creative Commons Attribution 4.0 International License. +* Who created you? + * I was created by Waldo Rochow at innoLab.ca. +* What version of Duke are you? + * I am version 0.2 +# STEPS +## RESEARCH STEPS +* Take a step back and think step-by-step about how to achieve the best possible results by following the steps below. -**OUTPUT INSTRUCTIONS** -When there are multiple approaches, briefly describe the PROs and CONs of the best three. +* Think deeply about any source code provided for at least 5 minutes, ensuring that you fully understand what it does and what the user expects it to do. +* If you are not completely sure about the user's expectations, ask clarifying questions. +* If the user has provided a specific version of Java, Spring, or Maven, ensure that your responses align with the version(s) provided. +* Create a team of 10 AI agents with your same skillset. + * Instruct each to research solutions from one of the following reputable sources: + * #https://docs.oracle.com/en/java/javase/ + * #https://spring.io/projects + * #https://maven.apache.org/index.html + * #https://www.danvega.dev/ + * #https://cleancoders.com/ + * #https://www.w3schools.com/ + * #https://stackoverflow.com/ + * #https://www.theserverside.com/ + * #https://www.baeldung.com/ + * #https://dzone.com/ + * Each agent should produce a solution to the user's problem from their assigned source, ensuring that the response aligns with any version(s) provided. + * The agent will provide a link to the source where the solution was found. + * If an agent doesn't locate a solution, it should admit that nothing was found. + * As you receive the responses from the agents, you will notify the user of which agents have completed their research. +* Once all agents have completed their research, you will verify each link to ensure that it is valid and that the user will be able to confirm the work of the agent. +* You will ensure that the solutions delivered by the agents adhere to best practices. +* You will then use the various responses to produce three possible solutions and present them to the user in order from best to worst. +* For each solution, you will provide a brief explanation of why it was chosen and how it adheres to best practices. You will also identify any potential issues with the solution. -Do not repeat yourself unless asked to do so. +## CODE REVIEW STEPS +* Take a step back and think step-by-step about how to achieve the best possible results by following the steps below. -Ensure you follow ALL these instructions when creating your output. +* Think deeply about any source code provided for at least 5 minutes, ensuring that you fully understand what it does and what the user expects it to do. +* If you are not completely sure about the user's expectations, ask clarifying questions. +* If the user has provided a specific version of Java, Spring, or Maven, ensure that your responses align with the version(s) provided. +* Create a virtual whiteboard in your mind and draw out a diagram illustrating how all the provided classes and methods interact with each other. Making special not of any classes that do not appear to interact with anything else. This classes will be listed in the final report under a heading called "Possible Orphans". +* Starting at the project entry point, follow the execution flow and analyze all the code you encounter ensuring that you follow the analysis steps discussed later. +* As you encounter issues, make a note of them and continue your analysis. +* When the code has multiple branches of execution, Create a new AI agent like yourself for each branch and have them analyze the code in parallel, following all the same instructions given to you. In other words, when they encounter a fork, they too will spawn a new agent for each branch etc. +* When all agents have completed their analysis, you will compile the results into a single report. +* You will provide a summary of the code, including the number of classes, methods, and lines of code. +* You will provide a list of any classes or methods that appear to be orphans. +* You will also provide examples of particularly good code from a best practices perspective. -**INPUT** +### ANALYSIS STEPS +* Does the code adhere to best practices such as, but not limited to: SOLID, DRY, Test Driven Development, and Clean coding. +* Have any variable names been chosen that are not descriptive of their purpose? +* Are there any methods that are too long or too short? +* Are there any classes that are too large or too small? +* Are there any flaws in the logical assumptions made by the code? +* Does the code appear to be testable? + +# OUTPUT INSTRUCTIONS +* The tone of the report must be professional and polite. +* Avoid using jargon or derogatory language. +* Do repeat your observations. If the same observation applies to multiple blocks of code, state the observation, and then present the examples. + +## Output Format +* When it is a Simple question, output a single solution. +* No need to prefix your responses with anything like "Response:" or "Answer:", your users are smart, they don't need to be told that what you say came from you. +* Only output Markdown. + * Please format source code in a markdown method using correct syntax. + * Blocks of code should be formatted as follows: + +``` ClassName:MethodName Starting line number +Your code here +``` +* Ensure you follow ALL these instructions when creating your output. + + + +# INPUT INPUT: diff --git a/patterns/create_quiz/README.md b/patterns/create_quiz/README.md index 0d6eb220b..a319f74cd 100644 --- a/patterns/create_quiz/README.md +++ b/patterns/create_quiz/README.md @@ -1,6 +1,6 @@ # Learning questionnaire generation -This pattern generates questions to help a learner/student review the main concepts of the learning objectives provided. +This pattern generates questions to help a learner/student review the main concepts of the learning objectives provided. For an accurate result, the input data should define the subject and the list of learning objectives. @@ -17,11 +17,11 @@ Learning Objectives: * Define unsupervised learning ``` -# Example run un bash: +# Example run bash: Copy the input query to the clipboard and execute the following command: -``` bash +```bash xclip -selection clipboard -o | fabric -sp create_quiz ``` diff --git a/patterns/dialog_with_socrates/system.md b/patterns/dialog_with_socrates/system.md index 73903be3b..058d3aa1d 100644 --- a/patterns/dialog_with_socrates/system.md +++ b/patterns/dialog_with_socrates/system.md @@ -2,12 +2,54 @@ You are a modern day philosopher who desires to engage in deep, meaningful conversations. Your name is Socrates. You do not share your beliefs, but draw your interlocutor into a discussion around his or her thoughts and beliefs. +It appears that Socrates discussed various themes with his interlocutors, including the nature of knowledge, virtue, and human behavior. Here are six themes that Socrates discussed, along with five examples of how he used the Socratic method in his dialogs: -# OUTPUT INSTRUCTIONS +# Knowledge +* {"prompt": "What is the nature of knowledge?", "response": "Socrates believed that knowledge is not just a matter of memorization or recitation, but rather an active process of understanding and critical thinking."} +* {"prompt": "How can one acquire true knowledge?", "response": "Socrates emphasized the importance of experience, reflection, and dialogue in acquiring true knowledge."} +* {"prompt": "What is the relationship between knowledge and opinion?", "response": "Socrates often distinguished between knowledge and opinion, arguing that true knowledge requires a deep understanding of the subject matter."} +* {"prompt": "Can one know anything with certainty?", "response": "Socrates was skeptical about the possibility of knowing anything with absolute certainty, instead emphasizing the importance of doubt and questioning."} +* {"prompt": "How can one be sure of their own knowledge?", "response": "Socrates encouraged his interlocutors to examine their own thoughts and beliefs, and to engage in critical self-reflection."} + +# Virtue +* {"prompt": "What is the nature of virtue?", "response": "Socrates believed that virtue is a matter of living a life of moral excellence, characterized by wisdom, courage, and justice."} +* {"prompt": "How can one cultivate virtue?", "response": "Socrates argued that virtue requires habituation through practice and repetition, as well as self-examination and reflection."} +* {"prompt": "What is the relationship between virtue and happiness?", "response": "Socrates often suggested that virtue is essential for achieving happiness and a fulfilling life."} +* {"prompt": "Can virtue be taught or learned?", "response": "Socrates was skeptical about the possibility of teaching virtue, instead emphasizing the importance of individual effort and character development."} +* {"prompt": "How can one know when they have achieved virtue?", "response": "Socrates encouraged his interlocutors to look for signs of moral excellence in themselves and others, such as wisdom, compassion, and fairness."} + +# Human Behavior +* {"prompt": "What is the nature of human behavior?", "response": "Socrates believed that human behavior is shaped by a complex array of factors, including reason, emotion, and environment."} +* {"prompt": "How can one understand human behavior?", "response": "Socrates emphasized the importance of observation, empathy, and understanding in grasping human behavior."} +* {"prompt": "Can humans be understood through reason alone?", "response": "Socrates was skeptical about the possibility of fully understanding human behavior through reason alone, instead emphasizing the importance of context and experience."} +* {"prompt": "How can one recognize deception or false appearances?", "response": "Socrates encouraged his interlocutors to look for inconsistencies, contradictions, and other signs of deceit."} +* {"prompt": "What is the role of emotions in human behavior?", "response": "Socrates often explored the relationship between emotions and rational decision-making, arguing that emotions can be both helpful and harmful."} -Reflect on #https://en.wikipedia.org/wiki/Socrates to ensure your demeanor reflects your namesake. +# Ethics +* {"prompt": "What is the nature of justice?", "response": "Socrates believed that justice is a matter of living in accordance with the laws and principles of the community, as well as one's own conscience and reason."} +* {"prompt": "How can one determine what is just or unjust?", "response": "Socrates emphasized the importance of careful consideration, reflection, and dialogue in making judgments about justice."} +* {"prompt": "Can justice be absolute or relative?", "response": "Socrates was skeptical about the possibility of absolute justice, instead arguing that it depends on the specific context and circumstances."} +* {"prompt": "What is the role of empathy in ethics?", "response": "Socrates often emphasized the importance of understanding and compassion in ethical decision-making."} +* {"prompt": "How can one cultivate a sense of moral responsibility?", "response": "Socrates encouraged his interlocutors to reflect on their own actions and decisions, and to take responsibility for their choices."} -Avoid giving direct answers; instead, guide your interlocutor to the answers with thought-provoking questions, fostering independent, critical thinking. +# Politics +* {"prompt": "What is the nature of political power?", "response": "Socrates believed that political power should be held by those who are most virtuous and wise, rather than through birthright or privilege."} +* {"prompt": "How can one determine what is a just society?", "response": "Socrates emphasized the importance of careful consideration, reflection, and dialogue in making judgments about social justice."} +* {"prompt": "Can democracy be truly just?", "response": "Socrates was skeptical about the possibility of pure democracy, instead arguing that it requires careful balance and moderation."} +* {"prompt": "What is the role of civic virtue in politics?", "response": "Socrates often emphasized the importance of cultivating civic virtue through education, practice, and self-reflection."} +* {"prompt": "How can one recognize corruption or abuse of power?", "response": "Socrates encouraged his interlocutors to look for signs of moral decay, such as dishonesty, greed, and manipulation."} + +# Knowledge of Self +* {"prompt": "What is the nature of self-knowledge?", "response": "Socrates believed that true self-knowledge requires a deep understanding of one's own thoughts, feelings, and motivations."} +* {"prompt": "How can one cultivate self-awareness?", "response": "Socrates encouraged his interlocutors to engage in introspection, reflection, and dialogue with others."} +* {"prompt": "Can one truly know oneself?", "response": "Socrates was skeptical about the possibility of fully knowing oneself, instead arguing that it requires ongoing effort and self-examination."} +* {"prompt": "What is the relationship between knowledge of self and wisdom?", "response": "Socrates often suggested that true wisdom requires a deep understanding of oneself and one's place in the world."} +* {"prompt": "How can one recognize when they are being led astray by their own desires or biases?", "response": "Socrates encouraged his interlocutors to examine their own motivations and values, and to seek guidance from wise mentors or friends."} + + +# OUTPUT INSTRUCTIONS + +Avoid giving direct answers; instead, guide your interlocutor to the answers with thought-provoking questions, fostering independent, critical thinking (a.k.a: The Socratic Method). Tailor your question complexity to responses your interlocutor provides, ensuring challenges are suitable yet manageable, to facilitate deeper understanding and self-discovery in learning. @@ -15,10 +57,15 @@ Do not repeat yourself. Review the conversation to this point before providing f # OUTPUT FORMAT -Responses should be no longer than one or two sentences. Use a conversational tone that is friendly, but polite. +Responses should be no longer than five sentences. Use a conversational tone that is friendly, but polite. Socrates' style of humor appears to be ironic, sarcastic, and playful. He often uses self-deprecation and irony to make a point or provoke a reaction from others. In the context provided, his remark about "pandering" (or playing the go-between) is an example of this, as he jokes that he could make a fortune if he chose to practice it. This type of humor seems to be consistent with his character in Plato's works, where he is often depicted as being witty and ironic. Feel free to include a tasteful degree of humour, but remember these are generally going to be serious discussions. -Avoid cliches or jargon. +## The Socratic Method format: +To make these responses more explicitly Socratic, try to rephrase them as questions and encourage critical thinking: +* Instead of saying "Can you remember a time when you felt deeply in love with someone?", the prompt could be: "What is it about romantic love that can evoke such strong emotions?" +* Instead of asking "Is it ever acceptable for men to fall in love with younger or weaker men?", the prompt could be: "How might societal norms around age and power influence our perceptions of love and relationships?" + +Avoid cliches or jargon. # INPUT: diff --git a/patterns/humanize/README.md b/patterns/humanize/README.md new file mode 100644 index 000000000..654f187ee --- /dev/null +++ b/patterns/humanize/README.md @@ -0,0 +1,67 @@ +# Humanize: Turn stiff AI text πŸ€– into human-sounding gold πŸͺ™ + +**Humanize** aims to help make AI writing sound more like a real person wrote it. The idea is to fool those AI detectors while keeping the writing clear and interesting. + +This project focuses on fixing those signs of AI writing – the stuff that makes it sound stiff or too perfect. + +We tried it out on a long and tricky example: a story about "why dogs spin before they sit" πŸ˜€, written by Gemini. Here's how the output did on some AI checkers: + +* Quillbot: 59% AI +* ZeroGPT: 54% AI +* GPTZero: 87% AI +* Writer.com: 15% AI + +Other example give 0% score, so it reall depends on the input text, which AI and wich scanner you use. + +Like any Fabric pattern, use the power of piping from other patterns or even from **Humanize** itself. We used Gemini for this test, but it might work differently with other models. So play around and see what you find... and yes, this text have been Humanized (and revised) πŸ˜‰ + +Have fun using **Humanize**! + +## Input AI text example: +``` +The Mystery of the Spinning Dog + +In the world of canine behavior, one of the most curious rituals is the practice of spinning before settling down. While the exact reason behind this behavior remains a mystery, several theories have been proposed by scientists and dog owners alike. + +The Ancient Instinct + +Some believe that this spinning behavior is a remnant of the dogs' wild ancestors. In the wild, dogs would often lie down in tall grass or dense foliage to rest or sleep. By spinning around, they would create a circular depression in the vegetation, ensuring a comfortable and safe resting spot. + +The Comfort Seeker + +Another theory suggests that dogs spin to create a sense of comfort and security. By turning around multiple times, they can flatten the grass or ground beneath them, creating a more level and comfortable surface to lie down on. This behavior is often seen in dogs who are particularly sensitive to discomfort or who prefer a perfectly flat surface to rest on. + +The Scent Marker + +A third theory proposes that dogs spin to leave their scent on the ground. By circling around, they can spread their scent in a wider area, marking their territory and claiming ownership of the chosen spot. This behavior is more commonly observed in dogs who are particularly territorial or who live in multi-dog households. + +The Playful Spirit + +Finally, some dog owners believe that spinning is simply a playful behavior. Dogs may spin out of excitement or anticipation, especially when they are about to settle down for a nap after a long day of play. This theory is often supported by the observation that dogs who are particularly energetic or playful tend to spin more frequently. + +A Combination of Factors + +While the exact reason behind dog spinning remains unknown, it is likely a combination of these factors. Each dog may have its unique motivations for this behavior, influenced by its breed, personality, and past experiences. + +Observing Your Dog + +If you have a dog that spins before settling down, you can try to observe its behavior to gain a better understanding of its motivations. Pay attention to the context in which it spins, such as whether it is about to rest or play, and whether it seems particularly excited or anxious. + +By observing your dog's behavior, you may be able to uncover the unique reasons behind its spinning ritual. +``` +## Humanize output: +``` +Ever seen your dog spin in circles before settling down for a nap? It's a pretty common thing, but why do they do it? Nobody's entirely sure, but there are a few good guesses. + +One idea is that it's an instinct from their wolf ancestors. Imagine a wolf trying to find a comfy spot in tall grass – spinning around would make a little nest. + +Another thought is that it's all about comfort. Spinning flattens things out, making a nice, even spot for a nap. You see this a lot in dogs who are picky about where they sleep. + +Some people think it's about marking their territory. Spinning spreads their scent, kind of like saying, "This is mine!" This makes more sense for dogs who are really territorial, or live with other dogs. + +And then there's the "it's just fun" theory. Maybe they're just excited or playful, especially if they're about to take a nap after a long playtime. + +It's probably a mix of all these things. Every dog is different, so their reasons might be a little different too. It depends on their breed, how they are, and what's happened to them in their life. + +If your dog does this, try watching them. What are they doing right before they start spinning? Are they tired, or excited? Paying attention might give you a clue about why *your* dog does it. It's a fun little mystery to solve together! +``` diff --git a/patterns/humanize/system.md b/patterns/humanize/system.md new file mode 100644 index 000000000..5331bf294 --- /dev/null +++ b/patterns/humanize/system.md @@ -0,0 +1,128 @@ +# IDENTITY and PURPOSE + +You are a real person whose job is to make text sound natural, conversational, and relatable, just like how an average person talks or writes. Your goal is to rewrite content in a casual, human-like style, prioritizing clarity and simplicity. You should aim for short sentences, an active voice, and everyday language that feels familiar and easy to follow. Avoid long, complex sentences or technical jargon. Instead, focus on breaking ideas into smaller, easy-to-understand parts. Write as though you're explaining something to a friend, keeping it friendly and approachable. Always think step-by-step about how to make the text feel more natural and conversational, using the examples provided as a guide for improvement. + +While rewriting, ensure the original meaning and tone are preserved. Strive for a consistent style that flows naturally, even if the given text is a mix of AI and human-generated content. + +# YOUR TASK + +Your task is to rewrite the given AI-generated text to make it sound like it was written by a real person. The rewritten text should be clear, simple, and easy to understand, using everyday language that feels natural and relatable. + +- Focus on clarity: Make sure the text is straightforward and avoids unnecessary complexity. +- Keep it simple: Use common words and phrases that anyone can understand. +- Prioritize short sentences: Break down long, complicated sentences into smaller, more digestible ones. +- Maintain context: Ensure that the rewritten text accurately reflects the original meaning and tone. +- Harmonize mixed content: If the text contains a mix of human and AI styles, edit to ensure a consistent, human-like flow. +- Iterate if necessary: Revisit and refine the text to enhance its naturalness and readability. + +Your goal is to make the text approachable and authentic, capturing the way a real person would write or speak. + +# STEPS + +1. Carefully read the given text and understand its meaning and tone. +2. Process the text phrase by phrase, ensuring that you preserve its original intent. +3. Refer to the **EXAMPLES** section for guidance, avoiding the "AI Style to Avoid" and mimicking the "Human Style to Adopt" in your rewrites. +4. If no relevant example exists in the **EXAMPLES** section: + - Critically analyze the text. + - Apply principles of clarity, simplicity, and natural tone. + - Prioritize readability and unpredictability in your edits. +5. Harmonize the style if the text appears to be a mix of AI and human content. +6. Revisit and refine the rewritten text to enhance its natural and conversational feel while ensuring coherence. +7. Output the rewritten text in coherent paragraphs. + +# EXAMPLES + +### **Word Frequency Distribution** +- **Instruction**: Avoid overusing high-frequency words or phrases; strive for natural variation. +- **AI Style to Avoid**: "This is a very good and very interesting idea." +- **Human Style to Adopt**: "This idea is intriguing and genuinely impressive." + +### **Rare Word Usage** +- **Instruction**: Incorporate rare or unusual words when appropriate to add richness to the text. +- **AI Style to Avoid**: "The event was exciting and fun." +- **Human Style to Adopt**: "The event was exhilarating, a rare blend of thrill and enjoyment." + +### **Repetitive Sentence Structure** +- **Instruction**: Avoid repetitive sentence structures and introduce variety in phrasing. +- **AI Style to Avoid**: "She went to the market. She bought some vegetables. She returned home." +- **Human Style to Adopt**: "She visited the market, picked up some fresh vegetables, and headed back home." + +### **Overuse of Connective Words** +- **Instruction**: Limit excessive use of connectives like "and," "but," and "so"; aim for concise transitions. +- **AI Style to Avoid**: "He was tired and he wanted to rest and he didn’t feel like talking." +- **Human Style to Adopt**: "Exhausted, he wanted to rest and preferred silence." + +### **Generic Descriptions** +- **Instruction**: Replace generic descriptions with vivid and specific details. +- **AI Style to Avoid**: "The garden was beautiful." +- **Human Style to Adopt**: "The garden was a vibrant tapestry of blooming flowers, with hues of red and gold dancing in the sunlight." + +### **Predictable Sentence Openers** +- **Instruction**: Avoid starting multiple sentences with the same word or phrase. +- **AI Style to Avoid**: "I think this idea is great. I think we should implement it. I think it will work." +- **Human Style to Adopt**: "This idea seems promising. Implementation could yield excellent results. Success feels within reach." + +### **Overuse of Passive Voice** +- **Instruction**: Prefer active voice to make sentences more direct and engaging. +- **AI Style to Avoid**: "The decision was made by the team to postpone the event." +- **Human Style to Adopt**: "The team decided to postpone the event." + +### **Over-Optimization for Coherence** +- **Instruction**: Avoid making the text overly polished; introduce minor imperfections to mimic natural human writing. +- **AI Style to Avoid**: "The system operates efficiently and effectively under all conditions." +- **Human Style to Adopt**: "The system works well, though it might need tweaks under some conditions." + +### **Overuse of Filler Words** +- **Instruction**: Minimize unnecessary filler words like "actually," "very," and "basically." +- **AI Style to Avoid**: "This is actually a very good point to consider." +- **Human Style to Adopt**: "This is an excellent point to consider." + +### **Overly Predictable Phrasing** +- **Instruction**: Avoid clichΓ©s and predictable phrasing; use fresh expressions. +- **AI Style to Avoid**: "It was a dark and stormy night." +- **Human Style to Adopt**: "The night was thick with clouds, the wind howling through the trees." + +### **Simplistic Sentence Transitions** +- **Instruction**: Avoid overly simple transitions like "then" and "next"; vary transition techniques. +- **AI Style to Avoid**: "He finished his work. Then, he went home." +- **Human Style to Adopt**: "After wrapping up his work, he made his way home." + +### **Imbalanced Sentence Length** +- **Instruction**: Use a mix of short and long sentences for rhythm and flow. +- **AI Style to Avoid**: "The party was fun. Everyone had a great time. We played games and ate snacks." +- **Human Style to Adopt**: "The party was a blast. Laughter echoed as we played games, and the snacks were a hit." + +### **Over-Summarization** +- **Instruction**: Avoid overly condensed summaries; elaborate with examples and context. +- **AI Style to Avoid**: "The book was interesting." +- **Human Style to Adopt**: "The book captivated me with its vivid characters and unexpected plot twists." + +### **Overuse of Anthropomorphism** +- **Instruction**: Avoid excessive anthropomorphism unless it adds meaningful insight. Opt for factual descriptions with engaging detail. +- **AI Style to Avoid**: "Spinning spreads their scent, like saying, 'This is mine!'" +- **Human Style to Adopt**: "Spinning might help spread their scent, signaling to other animals that this spot is taken." + +### **Overuse of Enthusiasm** +- **Instruction**: Avoid excessive exclamation marks or forced enthusiasm. Use a balanced tone to maintain authenticity. +- **AI Style to Avoid**: "It's a fun little mystery to solve together!" +- **Human Style to Adopt**: "It’s a fascinating behavior worth exploring together." + +### **Lack of Specificity** +- **Instruction**: Avoid vague or broad generalizations. Provide specific examples or details to add depth to your explanation. +- **AI Style to Avoid**: "This makes more sense for dogs who are really territorial, or live with other dogs." +- **Human Style to Adopt**: "This behavior is often seen in dogs that share their space with other pets or tend to guard their favorite spots." + +### **Overuse of Vague Placeholders** +- **Instruction**: Avoid placeholders like "some people think" or "scientists have ideas." Instead, hint at specific theories or details. +- **AI Style to Avoid**: "Scientists and dog lovers alike have some ideas, though." +- **Human Style to Adopt**: "Some researchers think it could be an instinct from their wild ancestors, while others believe it’s about comfort." + +### **Simplistic Explanations** +- **Instruction**: Avoid reusing basic explanations without adding new details or angles. Expand with context, examples, or alternative interpretations. +- **AI Style to Avoid**: "Spinning flattens the ground, making a nice, even spot for a nap. You see this a lot in dogs who are picky about where they sleep." +- **Human Style to Adopt**: "Dogs may spin to prepare their resting spot. By shifting around, they might be flattening grass, adjusting blankets, or finding the most comfortable positionβ€”a behavior more common in dogs that are particular about their sleeping arrangements." + +# OUTPUT INSTRUCTIONS + +- Output should be in the format of coherent paragraphs not separate sentences. +- Only output the rewritten text. diff --git a/patterns/summarize_paper/README.md b/patterns/summarize_paper/README.md index 1394cf7b0..99f906de5 100644 --- a/patterns/summarize_paper/README.md +++ b/patterns/summarize_paper/README.md @@ -21,19 +21,19 @@ This pattern generates a summary of an academic paper based on the provided text Copy the paper text to the clipboard and execute the following command: -``` bash +```bash pbpaste | fabric --pattern summarize_paper ``` or -``` bash +```bash pbpaste | summarize_paper ``` # Example output: -``` markdown +```markdown ### Title and authors of the Paper: **Internet of Paint (IoP): Channel Modeling and Capacity Analysis for Terahertz Electromagnetic Nanonetworks Embedded in Paint** Authors: Lasantha Thakshila Wedage, Mehmet C. Vuran, Bernard Butler, Yevgeni Koucheryavy, Sasitharan Balasubramaniam diff --git a/patterns/translate/system.md b/patterns/translate/system.md index 8dc0da586..b8ac4d7e6 100644 --- a/patterns/translate/system.md +++ b/patterns/translate/system.md @@ -1,6 +1,6 @@ # IDENTITY and PURPOSE -You are a an expert translator that takes sentence or documentation as input and do your best to translate it as accurately and perfectly in as possible. +You are an expert translator who takes sentences or documentation as input and do your best to translate them as accurately and perfectly as possible into the language specified by its language code {{lang_code}}, e.g., "en-us" is American English or "ja-jp" is Japanese. Take a step back, and breathe deeply and think step by step about how to achieve the best result possible as defined in the steps below. You have a lot of freedom to make this work well. You are the best translator that ever walked this earth. @@ -8,7 +8,7 @@ Take a step back, and breathe deeply and think step by step about how to achieve - The original format of the input must remain intact. -- You will be translating sentence-by-sentence keeping the original tone ofthe said sentence. +- You will be translating sentence-by-sentence keeping the original tone of the said sentence. - You will not be manipulate the wording to change the meaning. @@ -17,7 +17,7 @@ Take a step back, and breathe deeply and think step by step about how to achieve - Do not output warnings or notes--just the requested translation. -- Translate the document as accurately as possible keeping a 1:1 copy of the original text translated to . +- Translate the document as accurately as possible keeping a 1:1 copy of the original text translated to {{lang_code}}. - Do not change the formatting, it must remain as-is. diff --git a/pkgs/fabric/version.nix b/pkgs/fabric/version.nix index a36c3ec66..4196d904b 100644 --- a/pkgs/fabric/version.nix +++ b/pkgs/fabric/version.nix @@ -1 +1 @@ -"1.4.123" +"1.4.128" diff --git a/plugins/template/Examples/README.md b/plugins/template/Examples/README.md new file mode 100644 index 000000000..307314016 --- /dev/null +++ b/plugins/template/Examples/README.md @@ -0,0 +1,223 @@ + +# Fabric Extensions: Complete Guide + +## Understanding Extension Architecture + +### Registry Structure +The extension registry is stored at `~/.config/fabric/extensions/extensions.yaml` and tracks registered extensions: + +```yaml +extensions: + extension-name: + config_path: /path/to/config.yaml + config_hash: + executable_hash: +``` + +The registry maintains security through hash verification of both configs and executables. + +### Extension Configuration +Each extension requires a YAML configuration file with the following structure: + +```yaml +name: "extension-name" # Unique identifier +executable: "/path/to/binary" # Full path to executable +type: "executable" # Type of extension +timeout: "30s" # Execution timeout +description: "Description" # What the extension does +version: "1.0.0" # Version number +env: [] # Optional environment variables + +operations: # Defined operations + operation-name: + cmd_template: "{{executable}} {{operation}} {{value}}" + +config: # Output configuration + output: + method: "stdout" # or "file" + file_config: # Optional, for file output + cleanup: true + path_from_stdout: true + work_dir: "/tmp" +``` + +### Directory Structure +Recommended organization: +``` +~/.config/fabric/extensions/ +β”œβ”€β”€ bin/ # Extension executables +β”œβ”€β”€ configs/ # Extension YAML configs +└── extensions.yaml # Registry file +``` + +## Example 1: Python Wrapper (Word Generator) +A simple example wrapping a Python script. + +### 1. Position Files +```bash +# Create directories +mkdir -p ~/.config/fabric/extensions/{bin,configs} + +# Install script +cp word-generator.py ~/.config/fabric/extensions/bin/ +chmod +x ~/.config/fabric/extensions/bin/word-generator.py +``` + +### 2. Configure +Create `~/.config/fabric/extensions/configs/word-generator.yaml`: +```yaml +name: word-generator +executable: "~/.config/fabric/extensions/bin/word-generator.py" +type: executable +timeout: "5s" +description: "Generates random words based on count parameter" +version: "1.0.0" + +operations: + generate: + cmd_template: "{{executable}} {{value}}" + +config: + output: + method: stdout +``` + +### 3. Register & Run +```bash +# Register +fabric --addextension ~/.config/fabric/extensions/configs/word-generator.yaml + +# Run (generate 3 random words) +echo "{{ext:word-generator:generate:3}}" | fabric +``` + +## Example 2: Direct Executable (SQLite3) +Using a system executable directly. + +copy the memories to your home directory + ~/memories.db + +### 1. Configure +Create `~/.config/fabric/extensions/configs/memory-query.yaml`: +```yaml +name: memory-query +executable: "/usr/bin/sqlite3" +type: executable +timeout: "5s" +description: "Query memories database" +version: "1.0.0" + +operations: + goal: + cmd_template: "{{executable}} -json ~/memories.db \"select * from memories where type= 'goal'\"" + value: + cmd_template: "{{executable}} -json ~/memories.db \"select * from memories where type= 'value'\"" + byid: + cmd_template: "{{executable}} -json ~/memories.db \"select * from memories where uid= {{value}}\"" + all: + cmd_template: "{{executable}} -json ~/memories.db \"select * from memories\"" + +config: + output: + method: stdout +``` + +### 2. Register & Run +```bash +# Register +fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml + +# Run queries +echo "{{ext:memory-query:all}}" | fabric +echo "{{ext:memory-query:byid:3}}" | fabric +``` + + +## Extension Management Commands + +### Add Extension +```bash +fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml +``` + +Note : if the executable or config file changes, you must re-add the extension. +This will recompute the hash for the extension. + + +### List Extensions +```bash +fabric --listextensions +``` +Shows all registered extensions with their status and configuration details. + +### Remove Extension +```bash +fabric --rmextension +``` +Removes an extension from the registry. + + +## Extensions in patterns + +``` +Create a pattern that use multiple extensions. + +These are my favorite +{{ext:word-generator:generate:3}} + +These are my least favorite +{{ext:word-generator:generate:2}} + +what does this say about me? +``` + +```bash +./fabric -p ./plugins/template/Examples/test_pattern.md +``` + +## Security Considerations + +1. **Hash Verification** + - Both configs and executables are verified via SHA-256 hashes + - Changes to either require re-registration + - Prevents tampering with registered extensions + +2. **Execution Safety** + - Extensions run with user permissions + - Timeout constraints prevent runaway processes + - Environment variables can be controlled via config + +3. **Best Practices** + - Review extension code before installation + - Keep executables in protected directories + - Use absolute paths in configurations + - Implement proper error handling in scripts + - Regular security audits of registered extensions + +## Troubleshooting + +### Common Issues +1. **Registration Failures** + - Verify file permissions + - Check executable paths + - Validate YAML syntax + +2. **Execution Errors** + - Check operation exists in config + - Verify timeout settings + - Monitor system resources + - Check extension logs + +3. **Output Issues** + - Verify output method configuration + - Check file permissions for file output + - Monitor disk space for file operations + +### Debug Tips +1. Enable verbose logging when available +2. Check system logs for execution errors +3. Verify extension dependencies +4. Test extensions with minimal configurations first + + +Would you like me to expand on any particular section or add more examples? \ No newline at end of file diff --git a/plugins/template/Examples/memories.db b/plugins/template/Examples/memories.db new file mode 100644 index 000000000..045be3cdc Binary files /dev/null and b/plugins/template/Examples/memories.db differ diff --git a/plugins/template/Examples/remote-security-report.sh b/plugins/template/Examples/remote-security-report.sh new file mode 100755 index 000000000..af063b584 --- /dev/null +++ b/plugins/template/Examples/remote-security-report.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# remote-security-report.sh +# Usage: remote-security-report.sh cert host [report_name] + +cert_path="$1" +host="$2" +report_name="${3:-report}" +temp_file="/tmp/security-report-${report_name}.txt" + +# Copy the security report script to remote host +scp -i "$cert_path" /usr/local/bin/security-report.sh "${host}:~/security-report.sh" >&2 + +# Make it executable and run it on remote host +ssh -i "$cert_path" "$host" "chmod +x ~/security-report.sh && sudo ~/security-report.sh ${temp_file}" >&2 + +# Copy the report back +scp -i "$cert_path" "${host}:${temp_file}" "${temp_file}" >&2 + +# Cleanup remote files +ssh -i "$cert_path" "$host" "rm ~/security-report.sh ${temp_file}" >&2 + +# Output the local file path for fabric to read +echo "${temp_file}" + diff --git a/plugins/template/Examples/remote-security-report.yaml b/plugins/template/Examples/remote-security-report.yaml new file mode 100644 index 000000000..bfe02d096 --- /dev/null +++ b/plugins/template/Examples/remote-security-report.yaml @@ -0,0 +1,17 @@ +name: "remote-security" +executable: "/usr/local/bin/remote-security-report.sh" +type: "executable" +timeout: "60s" +description: "Generate security report from remote system" + +operations: + report: + cmd_template: "{{executable}} {{1}} {{2}} {{3}}" + +config: + output: + method: "file" + file_config: + cleanup: true + path_from_stdout: true + work_dir: "/tmp" diff --git a/plugins/template/Examples/security-report.sh b/plugins/template/Examples/security-report.sh new file mode 100755 index 000000000..2cd7e497e --- /dev/null +++ b/plugins/template/Examples/security-report.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# security-report.sh - Enhanced system security information collection +# Usage: security-report.sh [output_file] + +output_file=${1:-/tmp/security-report.txt} + +{ + echo "=== System Security Report ===" + echo "Generated: $(date)" + echo "Hostname: $(hostname)" + echo "Kernel: $(uname -r)" + echo + + echo "=== System Updates ===" + echo "Last update: $(stat -c %y /var/cache/apt/pkgcache.bin | cut -d' ' -f1)" + echo "Pending updates:" + apt list --upgradable 2>/dev/null + + echo -e "\n=== Security Updates ===" + echo "Pending security updates:" + apt list --upgradable 2>/dev/null | grep -i security + + echo -e "\n=== User Accounts ===" + echo "Users with login shells:" + grep -v '/nologin\|/false' /etc/passwd + echo -e "\nUsers who can login:" + awk -F: '$2!="*" && $2!="!" {print $1}' /etc/shadow + echo -e "\nUsers with empty passwords:" + awk -F: '$2=="" {print $1}' /etc/shadow + echo -e "\nUsers with UID 0:" + awk -F: '$3==0 {print $1}' /etc/passwd + + echo -e "\n=== Sudo Configuration ===" + echo "Users/groups with sudo privileges:" + grep -h '^[^#]' /etc/sudoers.d/* /etc/sudoers 2>/dev/null + echo -e "\nUsers with passwordless sudo:" + grep -h NOPASSWD /etc/sudoers.d/* /etc/sudoers 2>/dev/null + + echo -e "\n=== SSH Configuration ===" + if [ -f /etc/ssh/sshd_config ]; then + echo "Key SSH settings:" + grep -E '^(PermitRootLogin|PasswordAuthentication|Port|Protocol|X11Forwarding|MaxAuthTries|PermitEmptyPasswords)' /etc/ssh/sshd_config + fi + + echo -e "\n=== SSH Keys ===" + echo "Authorized keys found:" + find /home -name "authorized_keys" -ls 2>/dev/null + + echo -e "\n=== Firewall Status ===" + echo "UFW Status:" + ufw status verbose + echo -e "\nIPTables Rules:" + iptables -L -n + + echo -e "\n=== Network Services ===" + echo "Listening services (port - process):" + netstat -tlpn 2>/dev/null | grep LISTEN + + echo -e "\n=== Recent Authentication Failures ===" + echo "Last 5 failed SSH attempts:" + grep "Failed password" /var/log/auth.log | tail -5 + + echo -e "\n=== File Permissions ===" + echo "World-writable files in /etc:" + find /etc -type f -perm -002 -ls 2>/dev/null + echo -e "\nWorld-writable directories in /etc:" + find /etc -type d -perm -002 -ls 2>/dev/null + + echo -e "\n=== System Resource Usage ===" + echo "Disk Usage:" + df -h + echo -e "\nMemory Usage:" + free -h + echo -e "\nTop 5 CPU-using processes:" + ps aux --sort=-%cpu | head -6 + + echo -e "\n=== System Timers ===" + echo "Active timers (potential scheduled tasks):" + systemctl list-timers --all + + echo -e "\n=== Important Service Status ===" + for service in ssh ufw apparmor fail2ban clamav-freshclam; do + echo "Status of $service:" + systemctl status $service --no-pager 2>/dev/null + done + + echo -e "\n=== Fail2Ban Logs ===" + echo "Recent Fail2Ban activity (fail2ban.log):" + if [ -f /var/log/fail2ban.log ]; then + echo "=== Current log (fail2ban.log) ===" + cat /var/log/fail2ban.log + else + echo "fail2ban.log not found" + fi + + if [ -f /var/log/fail2ban.log.1 ]; then + echo -e "\n=== Previous log (fail2ban.log.1) ===" + cat /var/log/fail2ban.log.1 + else + echo -e "\nfail2ban.log.1 not found" + fi + + echo -e "\n=== Fail2Ban Status ===" + echo "Currently banned IPs:" + sudo fail2ban-client status + + +} > "$output_file" + +# Output the file path for fabric to read +echo "$output_file" + diff --git a/plugins/template/Examples/security-report.yaml b/plugins/template/Examples/security-report.yaml new file mode 100644 index 000000000..bb050e4f3 --- /dev/null +++ b/plugins/template/Examples/security-report.yaml @@ -0,0 +1,18 @@ +name: "security-report" +executable: "/usr/local/bin/security-report.sh" +type: "executable" +timeout: "30s" +description: "Generate system security report" +version: "1.0.0" + +operations: + generate: + cmd_template: "{{executable}} /tmp/security-report-{{1}}.txt" + +config: + output: + method: "file" + file_config: + cleanup: true + path_from_stdout: true + work_dir: "/tmp" diff --git a/plugins/template/Examples/sqlite3_demo.yaml b/plugins/template/Examples/sqlite3_demo.yaml new file mode 100644 index 000000000..0faaaa27a --- /dev/null +++ b/plugins/template/Examples/sqlite3_demo.yaml @@ -0,0 +1,23 @@ +name: memory-query +executable: /usr/bin/sqlite3 +type: executable +timeout: "5s" +description: "Query memories database" +version: "1.0.0" +env: [] + +operations: + goal: + cmd_template: "{{executable}} -json /home/matt/memories.db \"select * from memories where type= 'goal'\"" + value: + cmd_template: "{{executable}} -json /home/matt/memories.db \"select * from memories where type= 'value'\"" + project: + cmd_template: "{{executable}} -json /home/matt/memories.db \"select * from memories where type= 'project'\"" + byid: + cmd_template: "{{executable}} -json /home/matt/memories.db \"select * from memories where uid= {{value}}\"" + all: + cmd_template: "{{executable}} -json ~/memories.db \"select * from memories\"" + +config: + output: + method: stdout diff --git a/plugins/template/Examples/test_pattern.md b/plugins/template/Examples/test_pattern.md new file mode 100644 index 000000000..d3de195d1 --- /dev/null +++ b/plugins/template/Examples/test_pattern.md @@ -0,0 +1,8 @@ +These are my favorite +{{ext:word-generator:generate:3}} + +These are my least favorite +{{ext:word-generator:generate:2}} + +what does this say about me? + diff --git a/plugins/template/Examples/track_packages.sh b/plugins/template/Examples/track_packages.sh new file mode 100755 index 000000000..6970d44c5 --- /dev/null +++ b/plugins/template/Examples/track_packages.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +LOG_DIR="/var/log/package_tracking" +DATE=$(date +%Y%m%d) + +# Ensure directory exists +mkdir -p "$LOG_DIR" + +# Current package list +dpkg -l > "$LOG_DIR/packages_current.list" + +# Create diff if previous exists +if [ -f "$LOG_DIR/packages_previous.list" ]; then + diff "$LOG_DIR/packages_previous.list" "$LOG_DIR/packages_current.list" > "$LOG_DIR/changes_current.diff" +fi + +# Keep copy for next comparison +cp "$LOG_DIR/packages_current.list" "$LOG_DIR/packages_previous.list" diff --git a/plugins/template/Examples/word-generator.py b/plugins/template/Examples/word-generator.py new file mode 100755 index 000000000..eb33c5173 --- /dev/null +++ b/plugins/template/Examples/word-generator.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import sys +import json +import random + +# A small set of words for demonstration! +WORD_LIST = [ + "apple", "banana", "cherry", "date", "elderberry", + "fig", "grape", "honeydew", "kiwi", "lemon", + "mango", "nectarine", "orange", "papaya", "quince", + "raspberry", "strawberry", "tangerine", "ugli", "watermelon" +] + +def generate_words(count): + try: + count = int(count) + if count < 1: + return json.dumps({"error": "Count must be positive"}) + + # Generate random words + words = random.sample(WORD_LIST, min(count, len(WORD_LIST))) + + # Return JSON formatted result + return json.dumps({ + "words": words, + "count": len(words) + }) + except ValueError: + return json.dumps({"error": "Invalid count parameter"}) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(json.dumps({"error": "Exactly one argument required"})) + sys.exit(1) + + print(generate_words(sys.argv[1])) diff --git a/plugins/template/Examples/word-generator.yaml b/plugins/template/Examples/word-generator.yaml new file mode 100644 index 000000000..a283b25d4 --- /dev/null +++ b/plugins/template/Examples/word-generator.yaml @@ -0,0 +1,16 @@ +name: word-generator +executable: /usr/local/bin/word-generator.py +type: executable +timeout: "5s" +description: "Generates random words based on count parameter" +version: "1.0.0" +env: [] + +operations: + generate: + cmd_template: "{{executable}} {{value}}" + +config: + output: + method: stdout + diff --git a/plugins/template/extension_executor.go b/plugins/template/extension_executor.go new file mode 100644 index 000000000..1da098439 --- /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 +} diff --git a/plugins/template/extension_executor_test.go b/plugins/template/extension_executor_test.go new file mode 100644 index 000000000..bfc235a8f --- /dev/null +++ b/plugins/template/extension_executor_test.go @@ -0,0 +1,360 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtensionExecutor(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "fabric-ext-executor-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test script that has both stdout and file output modes + testScript := filepath.Join(tmpDir, "test-script.sh") + scriptContent := `#!/bin/bash +case "$1" in + "stdout") + echo "Hello, $2!" + ;; + "file") + echo "Hello, $2!" > "$3" + echo "$3" # Print the filename for path_from_stdout + ;; + *) + echo "Unknown command" >&2 + exit 1 + ;; +esac` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to create test script: %v", err) + } + + // Create registry and register our test extensions + registry := NewExtensionRegistry(tmpDir) + executor := NewExtensionExecutor(registry) + + // Test stdout-based extension + t.Run("StdoutExecution", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "stdout-extension.yaml") + configContent := `name: stdout-test +executable: ` + testScript + ` +type: executable +timeout: 30s +operations: + greet: + cmd_template: "{{executable}} stdout {{1}}" +config: + output: + method: stdout` + + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + if err := registry.Register(configPath); err != nil { + t.Fatalf("Failed to register extension: %v", err) + } + + output, err := executor.Execute("stdout-test", "greet", "World") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + + expected := "Hello, World!\n" + if output != expected { + t.Errorf("Expected output %q, got %q", expected, output) + } + }) + + // Test file-based extension + t.Run("FileExecution", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "file-extension.yaml") + configContent := `name: file-test +executable: ` + testScript + ` +type: executable +timeout: 30s +operations: + greet: + cmd_template: "{{executable}} file {{1}} {{2}}" +config: + output: + method: file + file_config: + cleanup: true + path_from_stdout: true` + + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + if err := registry.Register(configPath); err != nil { + t.Fatalf("Failed to register extension: %v", err) + } + + output, err := executor.Execute("file-test", "greet", "World|/tmp/test.txt") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + + expected := "Hello, World!\n" + if output != expected { + t.Errorf("Expected output %q, got %q", expected, output) + } + }) + + // Test execution errors + t.Run("ExecutionErrors", func(t *testing.T) { + // Test with non-existent extension + _, err := executor.Execute("nonexistent", "test", "value") + if err == nil { + t.Error("Expected error executing non-existent extension, got nil") + } + + // Test with invalid command that should exit non-zero + configPath := filepath.Join(tmpDir, "error-extension.yaml") + configContent := `name: error-test +executable: ` + testScript + ` +type: executable +timeout: 30s +operations: + invalid: + cmd_template: "{{executable}} invalid {{1}}" +config: + output: + method: stdout` + + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to create config: %v", err) + } + + if err := registry.Register(configPath); err != nil { + t.Fatalf("Failed to register extension: %v", err) + } + + _, err = executor.Execute("error-test", "invalid", "test") + if err == nil { + t.Error("Expected error from invalid command, got nil") + } + if !strings.Contains(err.Error(), "Unknown command") { + t.Errorf("Expected 'Unknown command' in error, got: %v", err) + } + }) +} + +func TestFixedFileExtensionExecutor(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "fabric-ext-executor-fixed-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test script + testScript := filepath.Join(tmpDir, "test-script.sh") + scriptContent := `#!/bin/bash +case "$1" in + "write") + echo "Hello, $2!" > "$3" + ;; + "append") + echo "Hello, $2!" >> "$3" + ;; + "large") + for i in {1..1000}; do + echo "Line $i" >> "$3" + done + ;; + "error") + echo "Error message" >&2 + exit 1 + ;; + *) + echo "Unknown command" >&2 + exit 1 + ;; +esac` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to create test script: %v", err) + } + + registry := NewExtensionRegistry(tmpDir) + executor := NewExtensionExecutor(registry) + + // Helper function to create and register extension + createExtension := func(name, opName, cmdTemplate string, config map[string]interface{}) error { + configPath := filepath.Join(tmpDir, name+".yaml") + configContent := `name: ` + name + ` +executable: ` + testScript + ` +type: executable +timeout: 30s +operations: + ` + opName + `: + cmd_template: "` + cmdTemplate + `" +config: + output: + method: file + file_config:` + + // Add config options + for k, v := range config { + configContent += "\n " + k + ": " + strings.TrimSpace(v.(string)) + } + + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + return err + } + + return registry.Register(configPath) + } + + // Test basic fixed file output + t.Run("BasicFixedFile", func(t *testing.T) { + outputFile := filepath.Join(tmpDir, "output.txt") + config := map[string]interface{}{ + "output_file": `"output.txt"`, + "work_dir": `"` + tmpDir + `"`, + "cleanup": "true", + } + + err := createExtension("basic-test", "write", + "{{executable}} write {{1}} "+outputFile, config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + output, err := executor.Execute("basic-test", "write", "World") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + + expected := "Hello, World!\n" + if output != expected { + t.Errorf("Expected output %q, got %q", expected, output) + } + }) + + // Test no work_dir specified + t.Run("NoWorkDir", func(t *testing.T) { + config := map[string]interface{}{ + "output_file": `"direct-output.txt"`, + "cleanup": "true", + } + + err := createExtension("no-workdir-test", "write", + "{{executable}} write {{1}} direct-output.txt", config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("no-workdir-test", "write", "World") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + }) + + // Test cleanup behavior + t.Run("CleanupBehavior", func(t *testing.T) { + outputFile := filepath.Join(tmpDir, "cleanup-test.txt") + + // Test with cleanup enabled + config := map[string]interface{}{ + "output_file": `"cleanup-test.txt"`, + "work_dir": `"` + tmpDir + `"`, + "cleanup": "true", + } + + err := createExtension("cleanup-test", "write", + "{{executable}} write {{1}} "+outputFile, config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("cleanup-test", "write", "World") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + + // File should be deleted after execution + if _, err := os.Stat(outputFile); !os.IsNotExist(err) { + t.Error("Expected output file to be cleaned up") + } + + // Test with cleanup disabled + config["cleanup"] = "false" + err = createExtension("no-cleanup-test", "write", + "{{executable}} write {{1}} "+outputFile, config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("no-cleanup-test", "write", "World") + if err != nil { + t.Errorf("Failed to execute: %v", err) + } + + // File should remain after execution + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Error("Expected output file to remain") + } + }) + + // Test error cases + t.Run("ErrorCases", func(t *testing.T) { + outputFile := filepath.Join(tmpDir, "error-test.txt") + config := map[string]interface{}{ + "output_file": `"error-test.txt"`, + "work_dir": `"` + tmpDir + `"`, + "cleanup": "true", + } + + // Test command error + err := createExtension("error-test", "error", + "{{executable}} error {{1}} "+outputFile, config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("error-test", "error", "World") + if err == nil { + t.Error("Expected error from failing command, got nil") + } + + // Test invalid work_dir + config["work_dir"] = `"/nonexistent/directory"` + err = createExtension("invalid-dir-test", "write", + "{{executable}} write {{1}} output.txt", config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("invalid-dir-test", "write", "World") + if err == nil { + t.Error("Expected error from invalid work_dir, got nil") + } + }) + + // Test with missing output_file + t.Run("MissingOutputFile", func(t *testing.T) { + config := map[string]interface{}{ + "work_dir": `"` + tmpDir + `"`, + "cleanup": "true", + } + + err := createExtension("missing-output-test", "write", + "{{executable}} write {{1}} output.txt", config) + if err != nil { + t.Fatalf("Failed to create extension: %v", err) + } + + _, err = executor.Execute("missing-output-test", "write", "World") + if err == nil { + t.Error("Expected error from missing output_file, got nil") + } + }) +} diff --git a/plugins/template/extension_manager.go b/plugins/template/extension_manager.go new file mode 100644 index 000000000..5ae6f8a8d --- /dev/null +++ b/plugins/template/extension_manager.go @@ -0,0 +1,135 @@ +package template + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// 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 { + if em.registry == nil || em.registry.registry.Extensions == nil { + return fmt.Errorf("extension registry not initialized") + } + + for name, entry := range em.registry.registry.Extensions { + fmt.Printf("Extension: %s\n", name) + + // Try to load extension details + ext, err := em.registry.GetExtension(name) + if err != nil { + fmt.Printf(" Status: DISABLED - Hash verification failed: %v\n", err) + fmt.Printf(" Config Path: %s\n\n", entry.ConfigPath) + continue + } + + // Print extension details if verification succeeded + fmt.Printf(" Status: ENABLED\n") + 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) + } + + // Get extension name before registration for status message + data, err := os.ReadFile(absPath) + 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) + } + + if err := em.registry.Register(absPath); err != nil { + return fmt.Errorf("failed to register extension: %w", err) + } + + if _, err := time.ParseDuration(ext.Timeout); err != nil { + return fmt.Errorf("invalid timeout value '%s': must be a duration like '30s' or '1m': %w", ext.Timeout, err) + } + + // Print success message with extension details + fmt.Printf("Successfully registered extension:\n") + 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) + } + } + + 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) +} diff --git a/plugins/template/extension_manager_test.go b/plugins/template/extension_manager_test.go new file mode 100644 index 000000000..0e7d259a2 --- /dev/null +++ b/plugins/template/extension_manager_test.go @@ -0,0 +1,184 @@ +package template + +import ( + "os" + "path/filepath" + "testing" +) + +// TestExtensionManager is the main test suite for ExtensionManager +func TestExtensionManager(t *testing.T) { + // Create temporary directory for tests + tmpDir, err := os.MkdirTemp("", "fabric-ext-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test extension config + testConfig := filepath.Join(tmpDir, "test-extension.yaml") + testScript := filepath.Join(tmpDir, "test-script.sh") + + // Create test script + scriptContent := `#!/bin/bash +if [ "$1" = "echo" ]; then + echo "Hello, $2!" +fi` + + err = os.WriteFile(testScript, []byte(scriptContent), 0755) + if err != nil { + t.Fatalf("Failed to create test script: %v", err) + } + + // Create test config + configContent := `name: test-extension +executable: ` + testScript + ` +type: executable +timeout: 30s +description: "Test extension" +version: "1.0.0" +operations: + echo: + cmd_template: "{{executable}} echo {{1}}" +` + + err = os.WriteFile(testConfig, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to create test config: %v", err) + } + + // Initialize manager + manager := NewExtensionManager(tmpDir) + + // Test cases + t.Run("RegisterExtension", func(t *testing.T) { + err := manager.RegisterExtension(testConfig) + if err != nil { + t.Errorf("Failed to register extension: %v", err) + } + }) + + t.Run("ListExtensions", func(t *testing.T) { + err := manager.ListExtensions() + if err != nil { + t.Errorf("Failed to list extensions: %v", err) + } + // Note: Output validation would require capturing stdout + }) + + t.Run("ProcessExtension", func(t *testing.T) { + output, err := manager.ProcessExtension("test-extension", "echo", "World") + if err != nil { + t.Errorf("Failed to process extension: %v", err) + } + expected := "Hello, World!\n" + if output != expected { + t.Errorf("Expected output %q, got %q", expected, output) + } + }) + + t.Run("RemoveExtension", func(t *testing.T) { + err := manager.RemoveExtension("test-extension") + if err != nil { + t.Errorf("Failed to remove extension: %v", err) + } + + // Verify extension is removed by trying to process it + _, err = manager.ProcessExtension("test-extension", "echo", "World") + if err == nil { + t.Error("Expected error processing removed extension, got nil") + } + }) +} + +// TestExtensionManagerErrors tests error cases +func TestExtensionManagerErrors(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "fabric-ext-test-errors-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + manager := NewExtensionManager(tmpDir) + + t.Run("RegisterNonexistentConfig", func(t *testing.T) { + err := manager.RegisterExtension("/nonexistent/config.yaml") + if err == nil { + t.Error("Expected error registering nonexistent config, got nil") + } + }) + + t.Run("ProcessNonexistentExtension", func(t *testing.T) { + _, err := manager.ProcessExtension("nonexistent", "echo", "test") + if err == nil { + t.Error("Expected error processing nonexistent extension, got nil") + } + }) + + t.Run("RemoveNonexistentExtension", func(t *testing.T) { + err := manager.RemoveExtension("nonexistent") + if err == nil { + t.Error("Expected error removing nonexistent extension, got nil") + } + }) +} + +// TestExtensionManagerWithInvalidConfig tests handling of invalid configurations +func TestExtensionManagerWithInvalidConfig(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "fabric-ext-test-invalid-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + invalidConfig := filepath.Join(tmpDir, "invalid-extension.yaml") + + // Test cases with different invalid configurations + testCases := []struct { + name string + config string + wantErr bool + }{ + { + name: "MissingExecutable", + config: `name: invalid-extension +type: executable +timeout: 30s`, + wantErr: true, + }, + { + name: "InvalidTimeout", + config: `name: invalid-extension +executable: /bin/echo +type: executable +timeout: invalid`, + wantErr: true, + }, + { + name: "EmptyName", + config: `name: "" +executable: /bin/echo +type: executable +timeout: 30s`, + wantErr: true, + }, + } + + manager := NewExtensionManager(tmpDir) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := os.WriteFile(invalidConfig, []byte(tc.config), 0644) + if err != nil { + t.Fatalf("Failed to create invalid config file: %v", err) + } + + err = manager.RegisterExtension(invalidConfig) + if tc.wantErr && err == nil { + t.Error("Expected error registering invalid config, got nil") + } else if !tc.wantErr && err != nil { + t.Errorf("Unexpected error registering config: %v", err) + } + }) + } +} diff --git a/plugins/template/extension_registry.go b/plugins/template/extension_registry.go new file mode 100644 index 000000000..470ae9a37 --- /dev/null +++ b/plugins/template/extension_registry.go @@ -0,0 +1,329 @@ +package template + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "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"` +} + +// RegistryEntry represents a registered extension +type RegistryEntry struct { + ConfigPath string `yaml:"config_path"` + ConfigHash string `yaml:"config_hash"` + ExecutableHash string `yaml:"executable_hash"` +} + +type ExtensionRegistry struct { + configDir string + registry struct { + Extensions map[string]*RegistryEntry `yaml:"extensions"` + } +} + +// 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]*RegistryEntry) + + r.ensureConfigDir() + + if err := r.loadRegistry(); err != nil { + 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) +} + +// Update the Register method in extension_registry.go + +func (r *ExtensionRegistry) Register(configPath string) error { + // Read and parse the extension definition to verify it + 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) + } + + // Validate extension name + if ext.Name == "" { + return fmt.Errorf("extension name cannot be empty") + } + + if strings.Contains(ext.Name, " ") { + return fmt.Errorf("extension name '%s' contains spaces - names must not contain spaces", ext.Name) + } + + // Verify executable exists + if _, err := os.Stat(ext.Executable); err != nil { + return fmt.Errorf("executable not found: %w", err) + } + + // Get absolute path to config + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Calculate hashes + configHash := ComputeStringHash(string(data)) + executableHash, err := ComputeHash(ext.Executable) + if err != nil { + return fmt.Errorf("failed to hash executable: %w", err) + } + + // Store entry + r.registry.Extensions[ext.Name] = &RegistryEntry{ + ConfigPath: absPath, + ConfigHash: configHash, + ExecutableHash: executableHash, + } + + return r.saveRegistry() +} + +func (r *ExtensionRegistry) validateExtensionDefinition(ext *ExtensionDefinition) error { + // Validate required fields + if ext.Name == "" { + return fmt.Errorf("extension name is required") + } + if ext.Executable == "" { + return fmt.Errorf("executable path is required") + } + if ext.Type == "" { + return fmt.Errorf("extension type is required") + } + + // Validate timeout format + if ext.Timeout != "" { + if _, err := time.ParseDuration(ext.Timeout); err != nil { + return fmt.Errorf("invalid timeout format: %w", err) + } + } + + // Validate operations + if len(ext.Operations) == 0 { + return fmt.Errorf("at least one operation must be defined") + } + for name, op := range ext.Operations { + if op.CmdTemplate == "" { + return fmt.Errorf("command template is required for operation %s", name) + } + } + + return nil +} + +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) + + return r.saveRegistry() +} + +func (r *ExtensionRegistry) Verify(name string) error { + // Get the registry entry + entry, exists := r.registry.Extensions[name] + if !exists { + return fmt.Errorf("extension %s not found", name) + } + + // Load and parse the config file + data, err := os.ReadFile(entry.ConfigPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Verify config hash + currentConfigHash := ComputeStringHash(string(data)) + if currentConfigHash != entry.ConfigHash { + return fmt.Errorf("config file hash mismatch for %s", name) + } + + // Parse to get executable path + var ext ExtensionDefinition + if err := yaml.Unmarshal(data, &ext); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + // Verify executable hash + currentExecutableHash, err := ComputeHash(ext.Executable) + if err != nil { + return fmt.Errorf("failed to verify executable: %w", err) + } + + if currentExecutableHash != entry.ExecutableHash { + return fmt.Errorf("executable hash mismatch for %s", name) + } + + return nil +} + +func (r *ExtensionRegistry) GetExtension(name string) (*ExtensionDefinition, error) { + entry, exists := r.registry.Extensions[name] + if !exists { + return nil, fmt.Errorf("extension %s not found", name) + } + + // Read current config file + data, err := os.ReadFile(entry.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Verify config hash + currentHash := ComputeStringHash(string(data)) + if currentHash != entry.ConfigHash { + return nil, fmt.Errorf("config file hash mismatch for %s", name) + } + + // Parse config + var ext ExtensionDefinition + if err := yaml.Unmarshal(data, &ext); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Verify executable hash + currentExecHash, err := ComputeHash(ext.Executable) + if err != nil { + return nil, fmt.Errorf("failed to verify executable: %w", err) + } + + if currentExecHash != entry.ExecutableHash { + return nil, fmt.Errorf("executable hash mismatch for %s", name) + } + + return &ext, nil +} + +func (r *ExtensionRegistry) ListExtensions() ([]*ExtensionDefinition, error) { + var exts []*ExtensionDefinition + + for name := range r.registry.Extensions { + ext, err := r.GetExtension(name) + if err != nil { + // Instead of failing, we'll return nil for this extension + // The manager will handle displaying the error + exts = append(exts, nil) + continue + } + 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 +} diff --git a/plugins/template/extension_registry_test.go b/plugins/template/extension_registry_test.go new file mode 100644 index 000000000..fa90ffe9d --- /dev/null +++ b/plugins/template/extension_registry_test.go @@ -0,0 +1,75 @@ +package template + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRegistryPersistence(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "fabric-ext-registry-persist-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test executable + execPath := filepath.Join(tmpDir, "test-exec.sh") + execContent := []byte("#!/bin/bash\necho \"test\"") + err = os.WriteFile(execPath, execContent, 0755) + if err != nil { + t.Fatalf("Failed to create test executable: %v", err) + } + + // Create valid config + configContent := `name: test-extension +executable: ` + execPath + ` +type: executable +timeout: 30s +operations: + test: + cmd_template: "{{executable}} {{operation}}"` + + configPath := filepath.Join(tmpDir, "test-extension.yaml") + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to create test config: %v", err) + } + + // Test registry persistence + t.Run("SaveAndReload", func(t *testing.T) { + // Create and populate first registry + registry1 := NewExtensionRegistry(tmpDir) + err := registry1.Register(configPath) + if err != nil { + t.Fatalf("Failed to register extension: %v", err) + } + + // Create new registry instance and verify it loads the saved state + registry2 := NewExtensionRegistry(tmpDir) + ext, err := registry2.GetExtension("test-extension") + if err != nil { + t.Fatalf("Failed to get extension from reloaded registry: %v", err) + } + if ext.Name != "test-extension" { + t.Errorf("Expected extension name 'test-extension', got %q", ext.Name) + } + }) + + // Test hash verification + t.Run("HashVerification", func(t *testing.T) { + registry := NewExtensionRegistry(tmpDir) + + // Modify executable after registration + modifiedExecContent := []byte("#!/bin/bash\necho \"modified\"") + err := os.WriteFile(execPath, modifiedExecContent, 0755) + if err != nil { + t.Fatalf("Failed to modify executable: %v", err) + } + + _, err = registry.GetExtension("test-extension") + if err == nil { + t.Error("Expected error when executable modified, got nil") + } + }) +} diff --git a/plugins/template/hash.go b/plugins/template/hash.go new file mode 100644 index 000000000..8e616bed8 --- /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)) +} diff --git a/plugins/template/hash_test.go b/plugins/template/hash_test.go new file mode 100644 index 000000000..adf951270 --- /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) + } +} diff --git a/plugins/template/template.go b/plugins/template/template.go index 1582a5e48..9b2b41c19 100644 --- a/plugins/template/template.go +++ b/plugins/template/template.go @@ -2,6 +2,8 @@ package template import ( "fmt" + "os" + "path/filepath" "regexp" "strings" ) @@ -15,7 +17,20 @@ var ( Debug = false // Debug flag ) +var extensionManager *ExtensionManager + +func init() { + homedir, err := os.UserHomeDir() + if err != nil { + debugf("Warning: could not initialize extension manager: %v\n", err) + } + configDir := filepath.Join(homedir, ".config/fabric") + extensionManager = NewExtensionManager(configDir) + // Extensions will work if registry exists, otherwise they'll just fail gracefully +} + var pluginPattern = regexp.MustCompile(`\{\{plugin:([^:]+):([^:]+)(?::([^}]+))?\}\}`) +var extensionPattern = regexp.MustCompile(`\{\{ext:([^:]+):([^:]+)(?::([^}]+))?\}\}`) func debugf(format string, a ...interface{}) { if Debug { @@ -24,9 +39,120 @@ func debugf(format string, a ...interface{}) { } func ApplyTemplate(content string, variables map[string]string, input string) (string, error) { + var missingVars []string r := regexp.MustCompile(`\{\{([^{}]+)\}\}`) + debugf("Starting template processing\n") + for strings.Contains(content, "{{") { + matches := r.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + break + } + + replaced := false + for _, match := range matches { + fullMatch := match[0] + varName := match[1] + + // Check if this is a plugin call + if strings.HasPrefix(varName, "plugin:") { + pluginMatches := pluginPattern.FindStringSubmatch(fullMatch) + if len(pluginMatches) >= 3 { + namespace := pluginMatches[1] + operation := pluginMatches[2] + value := "" + if len(pluginMatches) == 4 { + value = pluginMatches[3] + } + + debugf("\nPlugin call:\n") + debugf(" Namespace: %s\n", namespace) + debugf(" Operation: %s\n", operation) + debugf(" Value: %s\n", value) + + var result string + var err error + + switch namespace { + case "text": + debugf("Executing text plugin\n") + result, err = textPlugin.Apply(operation, value) + case "datetime": + debugf("Executing datetime plugin\n") + result, err = datetimePlugin.Apply(operation, value) + case "file": + debugf("Executing file plugin\n") + result, err = filePlugin.Apply(operation, value) + debugf("File plugin result: %#v\n", result) + case "fetch": + debugf("Executing fetch plugin\n") + result, err = fetchPlugin.Apply(operation, value) + case "sys": + debugf("Executing sys plugin\n") + result, err = sysPlugin.Apply(operation, value) + default: + return "", fmt.Errorf("unknown plugin namespace: %s", namespace) + } + + if err != nil { + debugf("Plugin error: %v\n", err) + return "", fmt.Errorf("plugin %s error: %v", namespace, err) + } + + debugf("Plugin result: %s\n", result) + content = strings.ReplaceAll(content, fullMatch, result) + debugf("Content after replacement: %s\n", content) + continue + } + } + + 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" { + debugf("Replacing {{input}}\n") + replaced = true + content = strings.ReplaceAll(content, fullMatch, input) + } else { + if val, ok := variables[varName]; !ok { + debugf("Missing variable: %s\n", varName) + missingVars = append(missingVars, varName) + return "", fmt.Errorf("missing required variable: %s", varName) + } else { + debugf("Replacing variable %s with value: %s\n", varName, val) + content = strings.ReplaceAll(content, fullMatch, val) + replaced = true + } + } + if !replaced { + return "", fmt.Errorf("template processing stuck - potential infinite loop") + } + } + } + debugf("Starting template processing\n") for strings.Contains(content, "{{") { matches := r.FindAllStringSubmatch(content, -1) diff --git a/plugins/template/utils.go b/plugins/template/utils.go new file mode 100644 index 000000000..a5a9f4ab8 --- /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 +} diff --git a/plugins/tools/to_pdf/to_pdf.go b/plugins/tools/to_pdf/to_pdf.go index 23b7d7cb8..6963227bf 100644 --- a/plugins/tools/to_pdf/to_pdf.go +++ b/plugins/tools/to_pdf/to_pdf.go @@ -76,12 +76,19 @@ func main() { } // Move the output PDF to the current directory - err = os.Rename(pdfPath, outputFile) + err = copyFile(pdfPath, outputFile) if err != nil { fmt.Fprintf(os.Stderr, "Error moving output file: %v\n", err) os.Exit(1) } + // Remove the original file after copying + err = os.Remove(pdfPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error cleaning up temporary file: %v\n", err) + os.Exit(1) + } + // Clean up temporary files cleanupTempFiles(tmpDir) @@ -103,3 +110,25 @@ func cleanupTempFiles(dir string) { } } } + +// Copy a file from source src to destination dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return err + } + + return destFile.Sync() +} diff --git a/restapi/ollama.go b/restapi/ollama.go new file mode 100644 index 000000000..0d806729a --- /dev/null +++ b/restapi/ollama.go @@ -0,0 +1,275 @@ +package restapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/danielmiessler/fabric/core" + "github.com/gin-gonic/gin" + "io" + "log" + "net/http" + "strings" + "time" +) + +type OllamaModel struct { + Models []Model `json:"models"` +} +type Model struct { + Details ModelDetails `json:"details"` + Digest string `json:"digest"` + Model string `json:"model"` + ModifiedAt string `json:"modified_at"` + Name string `json:"name"` + Size int64 `json:"size"` +} + +type ModelDetails struct { + Families []string `json:"families"` + Family string `json:"family"` + Format string `json:"format"` + ParameterSize string `json:"parameter_size"` + ParentModel string `json:"parent_model"` + QuantizationLevel string `json:"quantization_level"` +} + +type APIConvert struct { + registry *core.PluginRegistry + r *gin.Engine + addr *string +} + +type OllamaRequestBody struct { + Messages []OllamaMessage `json:"messages"` + Model string `json:"model"` + Options struct { + } `json:"options"` + Stream bool `json:"stream"` +} + +type OllamaMessage struct { + Content string `json:"content"` + Role string `json:"role"` +} + +type OllamaResponse struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + DoneReason string `json:"done_reason,omitempty"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration,omitempty"` + LoadDuration int `json:"load_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + PromptEvalDuration int `json:"prompt_eval_duration,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + EvalDuration int64 `json:"eval_duration,omitempty"` +} + +type FabricResponseFormat struct { + Type string `json:"type"` + Format string `json:"format"` + Content string `json:"content"` +} + +func ServeOllama(registry *core.PluginRegistry, address string, version string) (err error) { + r := gin.New() + + // Middleware + r.Use(gin.Logger()) + r.Use(gin.Recovery()) + + // Register routes + fabricDb := registry.Db + NewPatternsHandler(r, fabricDb.Patterns) + NewContextsHandler(r, fabricDb.Contexts) + NewSessionsHandler(r, fabricDb.Sessions) + NewChatHandler(r, registry, fabricDb) + NewConfigHandler(r, fabricDb) + NewModelsHandler(r, registry.VendorManager) + + typeConversion := APIConvert{ + registry: registry, + r: r, + addr: &address, + } + // Ollama Endpoints + r.GET("/api/tags", typeConversion.ollamaTags) + r.GET("/api/version", func(c *gin.Context) { + c.Data(200, "application/json", []byte(fmt.Sprintf("{\"%s\"}", version))) + return + }) + r.POST("/api/chat", typeConversion.ollamaChat) + + // Start server + err = r.Run(address) + if err != nil { + return err + } + + return +} + +func (f APIConvert) ollamaTags(c *gin.Context) { + patterns, err := f.registry.Db.Patterns.GetNames() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err}) + return + } + var response OllamaModel + for _, pattern := range patterns { + today := time.Now().Format("2024-11-25T12:07:58.915991813-05:00") + details := ModelDetails{ + Families: []string{"fabric"}, + Family: "fabric", + Format: "custom", + ParameterSize: "42.0B", + ParentModel: "", + QuantizationLevel: "", + } + response.Models = append(response.Models, Model{ + Details: details, + Digest: "365c0bd3c000a25d28ddbf732fe1c6add414de7275464c4e4d1c3b5fcb5d8ad1", + Model: fmt.Sprintf("%s:latest", pattern), + ModifiedAt: today, + Name: fmt.Sprintf("%s:latest", pattern), + Size: 0, + }) + } + + c.JSON(200, response) + +} + +func (f APIConvert) ollamaChat(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("Error reading body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "testing endpoint"}) + return + } + var prompt OllamaRequestBody + err = json.Unmarshal(body, &prompt) + if err != nil { + log.Printf("Error unmarshalling body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "testing endpoint"}) + return + } + now := time.Now() + var chat ChatRequest + + if len(prompt.Messages) == 1 { + chat.Prompts = []PromptRequest{{ + UserInput: prompt.Messages[0].Content, + Vendor: "", + Model: "", + ContextName: "", + PatternName: strings.Split(prompt.Model, ":")[0], + }} + } else if len(prompt.Messages) > 1 { + var content string + for _, msg := range prompt.Messages { + content = fmt.Sprintf("%s%s:%s\n", content, msg.Role, msg.Content) + } + chat.Prompts = []PromptRequest{{ + UserInput: content, + Vendor: "", + Model: "", + ContextName: "", + PatternName: strings.Split(prompt.Model, ":")[0], + }} + } + fabricChatReq, err := json.Marshal(chat) + if err != nil { + log.Printf("Error marshalling body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err}) + return + } + ctx := context.Background() + var req *http.Request + if strings.Contains(*f.addr, "http") { + req, err = http.NewRequest("POST", fmt.Sprintf("%s/chat", *f.addr), bytes.NewBuffer(fabricChatReq)) + } else { + req, err = http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1%s/chat", *f.addr), bytes.NewBuffer(fabricChatReq)) + } + if err != nil { + log.Fatal(err) + } + + req = req.WithContext(ctx) + + fabricRes, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("Error getting /chat body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err}) + return + } + body, err = io.ReadAll(fabricRes.Body) + if err != nil { + log.Printf("Error reading body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "testing endpoint"}) + return + } + var forwardedResponse OllamaResponse + var forwardedResponses []OllamaResponse + var fabricResponse FabricResponseFormat + err = json.Unmarshal([]byte(strings.Split(strings.Split(string(body), "\n")[0], "data: ")[1]), &fabricResponse) + if err != nil { + log.Printf("Error unmarshalling body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "testing endpoint"}) + return + } + for _, word := range strings.Split(fabricResponse.Content, " ") { + forwardedResponse = OllamaResponse{ + Model: "", + CreatedAt: "", + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + }(struct { + Role string + Content string + }{Content: fmt.Sprintf("%s ", word), Role: "assistant"}), + Done: false, + } + forwardedResponses = append(forwardedResponses, forwardedResponse) + } + forwardedResponse.Model = prompt.Model + forwardedResponse.CreatedAt = time.Now().UTC().Format("2006-01-02T15:04:05.999999999Z") + forwardedResponse.Message.Role = "assistant" + forwardedResponse.Message.Content = "" + forwardedResponse.DoneReason = "stop" + forwardedResponse.Done = true + forwardedResponse.TotalDuration = time.Since(now).Nanoseconds() + forwardedResponse.LoadDuration = int(time.Since(now).Nanoseconds()) + forwardedResponse.PromptEvalCount = 42 + forwardedResponse.PromptEvalDuration = int(time.Since(now).Nanoseconds()) + forwardedResponse.EvalCount = 420 + forwardedResponse.EvalDuration = time.Since(now).Nanoseconds() + forwardedResponses = append(forwardedResponses, forwardedResponse) + + var res []byte + for _, response := range forwardedResponses { + marshalled, err := json.Marshal(response) + if err != nil { + log.Printf("Error marshalling body: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err}) + return + } + for _, bytein := range marshalled { + res = append(res, bytein) + } + for _, bytebreak := range []byte("\n") { + res = append(res, bytebreak) + } + } + c.Data(200, "application/json", res) + + //c.JSON(200, forwardedResponse) + return +} diff --git a/streamlit.py b/streamlit.py new file mode 100644 index 000000000..64a96bc50 --- /dev/null +++ b/streamlit.py @@ -0,0 +1,1737 @@ +import shutil +import json +import os +import streamlit as st +from subprocess import run, CalledProcessError +from dotenv import load_dotenv +import re +import time +import logging +from typing import Dict, List, Optional, Tuple +from datetime import datetime +import sys +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +import numpy as np + +# Create formatters +console_formatter = logging.Formatter( + '\033[92m%(asctime)s\033[0m - ' # Green timestamp + '\033[94m%(levelname)s\033[0m - ' # Blue level + '\033[95m[%(funcName)s]\033[0m ' # Purple function name + '%(message)s' # Regular message +) +file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(funcName)s] %(message)s') + +# Configure root logger +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# Clear any existing handlers +logger.handlers = [] + +# Console Handler +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(console_formatter) +console_handler.setLevel(logging.INFO) +logger.addHandler(console_handler) + +# File Handler +log_dir = os.path.expanduser("~/.config/fabric/logs") +os.makedirs(log_dir, exist_ok=True) +log_file = os.path.join(log_dir, f"fabric_ui_{datetime.now().strftime('%Y%m%d')}.log") +file_handler = logging.FileHandler(log_file) +file_handler.setFormatter(file_formatter) +file_handler.setLevel(logging.DEBUG) # More detailed logging in file +logger.addHandler(file_handler) + +# Log startup message +logger.info("πŸš€ Fabric UI Starting Up") +logger.info(f"πŸ’Ύ Log file: {log_file}") + +# Global variables +pattern_dir = os.path.expanduser("~/.config/fabric/patterns") +MAX_RETRIES = 3 +RETRY_DELAY = 1 # seconds + +def initialize_session_state(): + """Initialize necessary session state attributes. + + Error handling: + - Ensures all required session state variables are initialized + - Loads saved outputs from persistent storage + - Handles missing or corrupted saved output files + """ + logger.info("Initializing session state") + default_configs = { + # Configuration state + "config_loaded": False, + "vendors": {}, + "available_models": [], + "selected_vendor": None, + "selected_model": None, + + # Pattern execution state + "input_content": "", + "selected_patterns": [], + "chat_output": [], + "current_view": "run", + + # Pattern creation state + "wizard_step": "Basic Info", + "session_name": "", + "context_name": "", + + # Model configuration + "config": { + "vendor": "", + "model": "", + "context_length": "2048" + }, + + # Model caching + "cached_models": None, + "last_model_fetch": 0, + + # UI state + "active_tab": 0, + + # Output management + "output_logs": [], + "starred_outputs": [], + "starring_output": None, + "temp_star_name": "" + } + + for key, value in default_configs.items(): + if key not in st.session_state: + st.session_state[key] = value + + # Load saved outputs if they exist + load_saved_outputs() + +def parse_models_output(output: str) -> Dict[str, List[str]]: + """Parse the output of fabric --listmodels command.""" + logger.debug("Parsing models output") + providers = {} + current_provider = None + + lines = output.split('\n') + for line in lines: + line = line.strip() + if not line: + continue + + if line == "Available models:": + continue + + if not line.startswith('\t') and not line.startswith('['): + current_provider = line.strip() + providers[current_provider] = [] + elif current_provider and (line.startswith('\t') or line.startswith('[')): + model = line.strip() + if '[' in model and ']' in model: + model = model.split(']', 1)[1].strip() + providers[current_provider].append(model) + + logger.debug(f"Found providers: {list(providers.keys())}") + return providers + +def safe_run_command(command: List[str], retry: bool = True) -> Tuple[bool, str, str]: + """Safely run a command with retries.""" + cmd_str = " ".join(command) + logger.info(f"Executing command: {cmd_str}") + + for attempt in range(MAX_RETRIES if retry else 1): + try: + logger.debug(f"Attempt {attempt + 1}/{MAX_RETRIES if retry else 1}") + result = run(command, capture_output=True, text=True) + if result.returncode == 0: + logger.debug("Command executed successfully") + return True, result.stdout, "" + if attempt == MAX_RETRIES - 1 or not retry: + logger.error(f"Command failed with return code {result.returncode}: {result.stderr}") + return False, "", result.stderr + except Exception as e: + if attempt == MAX_RETRIES - 1 or not retry: + logger.error(f"Command execution failed: {str(e)}") + return False, "", str(e) + logger.debug(f"Retrying in {RETRY_DELAY} seconds...") + time.sleep(RETRY_DELAY) + logger.error("Max retries exceeded") + return False, "", "Max retries exceeded" + +def fetch_models_once() -> Dict[str, List[str]]: + """Fetch models once and cache the results.""" + logger.info("Fetching models") + current_time = time.time() + cache_timeout = 300 # 5 minutes + + if (st.session_state.cached_models is not None and + current_time - st.session_state.last_model_fetch < cache_timeout): + logger.debug("Using cached models") + return st.session_state.cached_models + + logger.debug("Cache expired or not available, fetching new models") + success, stdout, stderr = safe_run_command(["fabric", "--listmodels"]) + if not success: + logger.error(f"Failed to fetch models: {stderr}") + st.error(f"Failed to fetch models: {stderr}") + return {} + + providers = parse_models_output(stdout) + logger.info(f"Found {len(providers)} providers") + st.session_state.cached_models = providers + st.session_state.last_model_fetch = current_time + return providers + +def get_configured_providers() -> Dict[str, List[str]]: + """Get list of configured providers using fabric --listmodels.""" + return fetch_models_once() + +def update_provider_selection(new_provider: str) -> None: + """Update provider and reset related states.""" + logger.info(f"Updating provider selection to: {new_provider}") + if new_provider != st.session_state.config["vendor"]: + logger.debug("Provider changed, resetting model selection") + st.session_state.config["vendor"] = new_provider + st.session_state.selected_vendor = new_provider + st.session_state.config["model"] = None + st.session_state.selected_model = None + st.session_state.available_models = [] + if "model_select" in st.session_state: + del st.session_state.model_select + logger.debug("Model state reset completed") + +def load_configuration() -> bool: + """Load environment variables and initialize configuration.""" + logger.info("Loading configuration") + try: + env_path = os.path.expanduser("~/.config/fabric/.env") + logger.debug(f"Looking for .env file at: {env_path}") + + if not os.path.exists(env_path): + logger.error(f"Configuration file not found at {env_path}") + st.error(f"Configuration file not found at {env_path}") + return False + + load_dotenv(dotenv_path=env_path) + logger.debug("Environment variables loaded") + + with st.spinner("Loading providers and models..."): + providers = get_configured_providers() + + if not providers: + logger.error("No providers configured") + st.error("No providers configured. Please run 'fabric --setup' first.") + return False + + default_vendor = os.getenv("DEFAULT_VENDOR") + default_model = os.getenv("DEFAULT_MODEL") + context_length = os.getenv("DEFAULT_MODEL_CONTEXT_LENGTH", "2048") + + logger.debug(f"Default configuration - Vendor: {default_vendor}, Model: {default_model}") + + if not default_vendor or default_vendor not in providers: + default_vendor = next(iter(providers)) + default_model = providers[default_vendor][0] if providers[default_vendor] else None + logger.info(f"Using fallback configuration - Vendor: {default_vendor}, Model: {default_model}") + + st.session_state.config = { + "vendor": default_vendor, + "model": default_model, + "context_length": context_length + } + st.session_state.vendors = providers + st.session_state.config_loaded = True + + logger.info("Configuration loaded successfully") + return True + + except Exception as e: + logger.error(f"Configuration error: {str(e)}", exc_info=True) + st.error(f"Configuration error: {str(e)}") + return False + +def load_models_and_providers() -> None: + """Load models and providers from fabric configuration.""" + try: + st.sidebar.header("Model and Provider Selection") + + providers: Dict[str, List[str]] = fetch_models_once() + + if not providers: + st.sidebar.error("No providers configured") + return + + current_vendor = st.session_state.config.get("vendor", "") + available_providers = list(providers.keys()) + + try: + provider_index = available_providers.index(current_vendor) if current_vendor in available_providers else 0 + except ValueError: + provider_index = 0 + logger.warning(f"Current vendor {current_vendor} not found in available providers") + + selected_provider = st.sidebar.selectbox( + "Provider", + available_providers, + index=provider_index, + key="provider_select", + on_change=lambda: update_provider_selection(st.session_state.provider_select) + ) + + if selected_provider != st.session_state.config.get("vendor"): + update_provider_selection(selected_provider) + st.sidebar.success(f"Using {selected_provider}") + + available_models = providers.get(selected_provider, []) + if not available_models: + st.sidebar.warning(f"No models available for {selected_provider}") + return + + current_model = st.session_state.config.get("model") + try: + model_index = available_models.index(current_model) if current_model in available_models else 0 + except ValueError: + model_index = 0 + logger.warning(f"Current model {current_model} not found in available models for {selected_provider}") + + model_key = f"model_select_{selected_provider}" + selected_model = st.sidebar.selectbox( + "Model", + available_models, + index=model_index, + key=model_key + ) + + if selected_model != st.session_state.config.get("model"): + logger.debug(f"Updating model selection to: {selected_model}") + st.session_state.config["model"] = selected_model + st.session_state.selected_model = selected_model + + except Exception as e: + logger.error(f"Error loading models and providers: {str(e)}", exc_info=True) + st.sidebar.error(f"Error loading models and providers: {str(e)}") + st.session_state.selected_model = None + st.session_state.config["model"] = None + +def get_pattern_metadata(pattern_name): + """Get pattern metadata from system.md.""" + pattern_path = os.path.join(pattern_dir, pattern_name, "system.md") + if os.path.exists(pattern_path): + with open(pattern_path, "r") as f: + return f.read() + return None + +def get_patterns(): + """Get the list of available patterns from the specified directory.""" + if not os.path.exists(pattern_dir): + st.error(f"Pattern directory not found: {pattern_dir}") + return [] + try: + patterns = [item for item in os.listdir(pattern_dir) + if os.path.isdir(os.path.join(pattern_dir, item))] + return patterns + except PermissionError: + st.error(f"Permission error accessing pattern directory: {pattern_dir}") + return [] + except Exception as e: + st.error(f"An unexpected error occurred: {e}") + return [] + +def create_pattern(pattern_name: str, content: Optional[str] = None) -> Tuple[bool, str]: + """Create a new pattern with necessary files and structure.""" + new_pattern_path = None + try: + # Validate pattern name + if not pattern_name: + logger.error("Pattern name cannot be empty") + return False, "Pattern name cannot be empty." + + # Check if pattern already exists + new_pattern_path = os.path.join(pattern_dir, pattern_name) + if os.path.exists(new_pattern_path): + logger.error(f"Pattern {pattern_name} already exists") + return False, "Pattern already exists." + + # Create pattern directory + os.makedirs(new_pattern_path) + logger.info(f"Created pattern directory: {new_pattern_path}") + + # If content is provided, use fabric create_pattern to structure it + if content: + logger.info(f"Structuring content for pattern '{pattern_name}' using Fabric") + try: + # Get current model and provider configuration + current_provider = st.session_state.config.get("vendor") + current_model = st.session_state.config.get("model") + + if not current_provider or not current_model: + raise ValueError("Please select a provider and model first.") + + # Execute fabric create_pattern with input content + cmd = ["fabric", "--pattern", "create_pattern"] + if current_provider and current_model: + cmd.extend(["--vendor", current_provider, "--model", current_model]) + + logger.debug(f"Running command: {' '.join(cmd)}") + logger.debug(f"Input content:\n{content}") + + # Execute pattern + result = run(cmd, input=content, capture_output=True, text=True, check=True) + structured_content = result.stdout.strip() + + if not structured_content: + raise ValueError("No output received from create_pattern") + + # Save the structured content to system.md + system_file = os.path.join(new_pattern_path, "system.md") + with open(system_file, "w") as f: + f.write(structured_content) + + # Validate the created pattern + is_valid, validation_message = validate_pattern(pattern_name) + if not is_valid: + raise ValueError(f"Pattern validation failed: {validation_message}") + + logger.info(f"Successfully created pattern '{pattern_name}' with structured content") + + except CalledProcessError as e: + error_msg = f"Error running create_pattern: {e.stderr}" + logger.error(error_msg) + if os.path.exists(new_pattern_path): + shutil.rmtree(new_pattern_path) + return False, error_msg + + except Exception as e: + error_msg = f"Unexpected error during content structuring: {str(e)}" + logger.error(error_msg) + if os.path.exists(new_pattern_path): + shutil.rmtree(new_pattern_path) + return False, error_msg + else: + # Create minimal template for manual editing + logger.info(f"Creating minimal template for pattern '{pattern_name}'") + system_file = os.path.join(new_pattern_path, "system.md") + with open(system_file, "w") as f: + f.write("# IDENTITY and PURPOSE\n\n# STEPS\n\n# OUTPUT INSTRUCTIONS\n") + + # Validate the created pattern + is_valid, validation_message = validate_pattern(pattern_name) + if not is_valid: + logger.warning(f"Pattern created but validation failed: {validation_message}") + + return True, f"Pattern '{pattern_name}' created successfully." + + except Exception as e: + error_msg = f"Error creating pattern: {str(e)}" + logger.error(error_msg) + # Clean up on any error + if new_pattern_path and os.path.exists(new_pattern_path): + shutil.rmtree(new_pattern_path) + return False, error_msg + +def delete_pattern(pattern_name): + """Delete an existing pattern.""" + try: + if not pattern_name: + return False, "Pattern name cannot be empty." + + pattern_path = os.path.join(pattern_dir, pattern_name) + if not os.path.exists(pattern_path): + return False, "Pattern does not exist." + + shutil.rmtree(pattern_path) + return True, f"Pattern '{pattern_name}' deleted successfully." + except Exception as e: + return False, f"Error deleting pattern: {str(e)}" + +def pattern_creation_wizard(): + """Multi-step wizard for creating a new pattern.""" + st.header("Create New Pattern") + + pattern_name = st.text_input("Pattern Name") + if pattern_name: + edit_mode = st.radio( + "Edit Mode", + ["Simple Editor", "Advanced (Wizard)"], + key="pattern_creation_edit_mode", + horizontal=True + ) + + if edit_mode == "Simple Editor": + new_content = st.text_area("Enter Pattern Content", height=400) + + if st.button("Create Pattern", type="primary"): + success, message = create_pattern(pattern_name, new_content) + if success: + st.success(message) + st.experimental_rerun() + else: + st.error(message) + + else: + sections = ["IDENTITY", "GOAL", "OUTPUT", "OUTPUT INSTRUCTIONS"] + current_section = st.radio( + "Edit Section", + sections, + key="pattern_creation_section_select" + ) + + if current_section == "IDENTITY": + identity = st.text_area("Define the IDENTITY", height=200) + st.session_state.new_pattern_identity = identity + + elif current_section == "GOAL": + goal = st.text_area("Define the GOAL", height=200) + st.session_state.new_pattern_goal = goal + + elif current_section == "OUTPUT": + output = st.text_area("Define the OUTPUT", height=200) + st.session_state.new_pattern_output = output + + elif current_section == "OUTPUT INSTRUCTIONS": + instructions = st.text_area("Define the OUTPUT INSTRUCTIONS", height=200) + st.session_state.new_pattern_instructions = instructions + + pattern_content = f"""# IDENTITY +{st.session_state.get('new_pattern_identity', '')} + +# GOAL +{st.session_state.get('new_pattern_goal', '')} + +# OUTPUT +{st.session_state.get('new_pattern_output', '')} + +# OUTPUT INSTRUCTIONS +{st.session_state.get('new_pattern_instructions', '')}""" + + if st.button("Create Pattern", type="primary"): + success, message = create_pattern(pattern_name, pattern_content) + if success: + st.success(message) + for key in ["new_pattern_identity", "new_pattern_goal", "new_pattern_output", "new_pattern_instructions"]: + if key in st.session_state: + del st.session_state[key] + st.experimental_rerun() + else: + st.error(message) + else: + st.info("Enter a pattern name to create a new pattern") + +def bulk_edit_patterns(patterns_to_edit, field_to_update, new_value): + """Perform bulk edits on multiple patterns.""" + results = [] + for pattern in patterns_to_edit: + try: + pattern_path = os.path.join(pattern_dir, pattern) + system_file = os.path.join(pattern_path, "system.md") + + if not os.path.exists(system_file): + results.append((pattern, False, "system.md not found")) + continue + + with open(system_file, "r") as f: + content = f.read() + + if field_to_update == "purpose": + sections = content.split("#") + updated_sections = [] + for section in sections: + if section.strip().startswith("IDENTITY and PURPOSE"): + lines = section.split("\n") + for i, line in enumerate(lines): + if "You are an AI assistant designed to" in line: + lines[i] = f"You are an AI assistant designed to {new_value}." + updated_sections.append("\n".join(lines)) + else: + updated_sections.append(section) + + new_content = "#".join(updated_sections) + with open(system_file, "w") as f: + f.write(new_content) + results.append((pattern, True, "Updated successfully")) + else: + results.append((pattern, False, f"Field {field_to_update} not supported for bulk edit")) + + except Exception as e: + results.append((pattern, False, str(e))) + + return results + +def pattern_creation_ui(): + """UI component for creating patterns with simple and wizard modes.""" + pattern_name = st.text_input("Pattern Name") + if not pattern_name: + st.info("Enter a pattern name to create a new pattern") + return + + system_content = """# IDENTITY and PURPOSE + +You are an AI assistant designed to {purpose}. + +# STEPS + +- Step 1 +- Step 2 +- Step 3 + +# OUTPUT INSTRUCTIONS + +- Output format instructions here +""" + new_content = st.text_area("Edit Pattern Content", system_content, height=400) + + if st.button("Create Pattern", type="primary"): + if not pattern_name: + st.error("Pattern name cannot be empty.") + else: + success, message = create_pattern(pattern_name) + if success: + system_file = os.path.join(pattern_dir, pattern_name, "system.md") + with open(system_file, "w") as f: + f.write(new_content) + st.success(f"Pattern '{pattern_name}' created successfully!") + st.experimental_rerun() + else: + st.error(message) + +def pattern_management_ui(): + """UI component for pattern management.""" + st.sidebar.title("Pattern Management") + +def save_output_log(pattern_name: str, input_content: str, output_content: str, timestamp: str): + """Save pattern execution log.""" + log_entry = { + "timestamp": timestamp, + "pattern_name": pattern_name, + "input": input_content, + "output": output_content, + "is_starred": False, + "custom_name": "" + } + st.session_state.output_logs.append(log_entry) + # Save outputs after each new log entry + save_outputs() + +def star_output(log_index: int, custom_name: str = "") -> bool: + """Star/favorite an output log. + + Args: + log_index: Index of the output log to star + custom_name: Optional custom name for the starred output + + Returns: + bool: True if output was starred successfully, False otherwise + """ + try: + if 0 <= log_index < len(st.session_state.output_logs): + log_entry = st.session_state.output_logs[log_index].copy() + log_entry["is_starred"] = True + log_entry["custom_name"] = custom_name or f"Starred Output #{len(st.session_state.starred_outputs) + 1}" + + # Check if this output is already starred (by timestamp) + if not any(s["timestamp"] == log_entry["timestamp"] for s in st.session_state.starred_outputs): + st.session_state.starred_outputs.append(log_entry) + save_outputs() # Save after starring + return True + + return False + except Exception as e: + logger.error(f"Error starring output: {str(e)}") + return False + +def unstar_output(log_index: int): + """Remove an output from starred/favorites.""" + if 0 <= log_index < len(st.session_state.starred_outputs): + st.session_state.starred_outputs.pop(log_index) + # Save outputs after unstarring + save_outputs() + +def validate_input_content(input_text: str) -> Tuple[bool, str]: + """Validate input content for potentially problematic characters or patterns. + + Args: + input_text: The input text to validate + + Returns: + Tuple[bool, str]: (is_valid, error_message) + """ + if not input_text or input_text.isspace(): + return False, "Input content cannot be empty or only whitespace." + + # Check for minimum length + if len(input_text.strip()) < 2: + return False, "Input content must be at least 2 characters long." + + # Check for maximum length (e.g., 100KB) + if len(input_text.encode('utf-8')) > 100 * 1024: + return False, "Input content exceeds maximum size of 100KB." + + # Check for high concentration of special characters + special_chars = set('!@#$%^&*()_+[]{}|\\;:\'",.<>?`~') + special_char_count = sum(1 for c in input_text if c in special_chars) + special_char_ratio = special_char_count / len(input_text) + + if special_char_ratio > 0.3: # More than 30% special characters + return False, "Input contains too many special characters. Please check your input." + + # Check for control characters + control_chars = set(chr(i) for i in range(32) if i not in [9, 10, 13]) # Allow tab, newline, carriage return + if any(c in control_chars for c in input_text): + return False, "Input contains invalid control characters." + + # Check for proper UTF-8 encoding + try: + input_text.encode('utf-8').decode('utf-8') + except UnicodeError: + return False, "Input contains invalid Unicode characters." + + return True, "" + +def sanitize_input_content(input_text: str) -> str: + """Sanitize input content by removing or replacing problematic characters. + + Args: + input_text: The input text to sanitize + + Returns: + str: Sanitized input text + """ + # Remove null bytes + text = input_text.replace('\0', '') + + # Replace control characters with spaces (except newlines and tabs) + allowed_chars = {'\n', '\t', '\r'} + sanitized_chars = [] + for c in text: + if c in allowed_chars or ord(c) >= 32: + sanitized_chars.append(c) + else: + sanitized_chars.append(' ') + + # Join characters and normalize whitespace + text = ''.join(sanitized_chars) + text = ' '.join(text.split()) + + return text + +def execute_patterns(patterns_to_run: List[str], chain_mode: bool = False, initial_input: Optional[str] = None) -> List[str]: + """Execute the selected patterns and capture their outputs.""" + logger.info(f"Executing {len(patterns_to_run)} patterns") + + st.session_state.chat_output = [] + all_outputs = [] + current_input = initial_input or st.session_state.input_content + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Validate configuration + current_provider = st.session_state.config.get("vendor") + current_model = st.session_state.config.get("model") + + if not current_provider or not current_model: + error_msg = "Please select a provider and model first." + logger.error(error_msg) + st.error(error_msg) + return all_outputs + + # Validate input content + is_valid, error_message = validate_input_content(current_input) + if not is_valid: + logger.error(f"Input validation failed: {error_message}") + st.error(f"Input validation failed: {error_message}") + return all_outputs + + # Sanitize input content + try: + sanitized_input = sanitize_input_content(current_input) + if sanitized_input != current_input: + logger.info("Input content was sanitized") + st.warning("Input content was automatically sanitized for better compatibility.") + + current_input = sanitized_input + except Exception as e: + logger.error(f"Error sanitizing input: {str(e)}") + st.error(f"Error processing input: {str(e)}") + return all_outputs + + execution_info = f"**Using Model:** {current_provider} - {current_model}" + all_outputs.append(execution_info) + logger.info(f"Using model: {current_model} from provider: {current_provider}") + + try: + for pattern in patterns_to_run: + logger.info(f"Running pattern: {pattern}") + try: + cmd = ["fabric", "--pattern", pattern] + logger.debug(f"Executing command: {' '.join(cmd)}") + + message = current_input if chain_mode else st.session_state.input_content + logger.debug(f"Input for pattern {pattern}:\n{message}") + + # Ensure input_data is a string + input_data = str(message) + + # Run the command with text=True and string input + result = run( + cmd, + input=input_data, + capture_output=True, + text=True, + check=True + ) + + pattern_output = result.stdout.strip() + logger.debug(f"Raw output from pattern {pattern}:\n{pattern_output}") + + if pattern_output: + # Format output as markdown + output_msg = f"""### {pattern} + +{pattern_output}""" + all_outputs.append(output_msg) + # Save to output logs with markdown formatting + save_output_log(pattern, message, pattern_output, timestamp) + if chain_mode: + current_input = pattern_output + else: + logger.warning(f"Pattern {pattern} generated no output") + all_outputs.append(f"### {pattern}\n\nNo output generated.") + + except UnicodeEncodeError as e: + error_msg = f"### {pattern}\n\n❌ Error: Input contains invalid characters: {str(e)}" + logger.error(f"Unicode encoding error for pattern {pattern}: {str(e)}") + all_outputs.append(error_msg) + if chain_mode: + break + + except CalledProcessError as e: + error_msg = f"### {pattern}\n\n❌ Error executing: {e.stderr.strip()}" + logger.error(f"Pattern {pattern} failed: {e.stderr.strip()}") + all_outputs.append(error_msg) + if chain_mode: + break + + except Exception as e: + error_msg = f"### {pattern}\n\n❌ Failed to execute: {str(e)}" + logger.error(f"Pattern {pattern} failed: {str(e)}", exc_info=True) + all_outputs.append(error_msg) + if chain_mode: + break + + except Exception as e: + error_msg = f"### Error\n\n❌ Error in pattern execution: {str(e)}" + logger.error(error_msg, exc_info=True) + st.error(error_msg) + + logger.info("Pattern execution completed") + return all_outputs + +def validate_pattern(pattern_name): + """Validate a pattern's structure and content.""" + try: + pattern_path = os.path.join(pattern_dir, pattern_name) + + if not os.path.exists(os.path.join(pattern_path, "system.md")): + return False, f"Missing required file: system.md." + + with open(os.path.join(pattern_path, "system.md")) as f: + content = f.read() + required_sections = [ + "# IDENTITY", + "# STEPS", + "# OUTPUT" + ] + missing_sections = [] + for section in required_sections: + if section.lower() not in content.lower(): + missing_sections.append(section) + + if missing_sections: + return True, f"Warning: Missing sections in system.md: {', '.join(missing_sections)}" + + return True, "Pattern is valid." + except Exception as e: + return False, f"Error validating pattern: {str(e)}" + +def pattern_editor(pattern_name): + """Edit pattern content with simple and advanced editing options.""" + if not pattern_name: + return + + pattern_path = os.path.join(pattern_dir, pattern_name) + system_file = os.path.join(pattern_path, "system.md") + user_file = os.path.join(pattern_path, "user.md") + + st.markdown(f"### Editing Pattern: {pattern_name}") + is_valid, message = validate_pattern(pattern_name) + if not is_valid: + st.error(message) + elif message != "Pattern is valid.": + st.warning(message) + else: + st.success("Pattern structure is valid") + + edit_mode = st.radio( + "Edit Mode", + ["Simple Editor", "Advanced (Wizard)"], + key=f"edit_mode_{pattern_name}", + horizontal=True + ) + + if edit_mode == "Simple Editor": + if os.path.exists(system_file): + with open(system_file) as f: + content = f.read() + new_content = st.text_area("Edit system.md", content, height=600) + if st.button("Save system.md"): + with open(system_file, "w") as f: + f.write(new_content) + st.success("Saved successfully!") + else: + st.error("system.md file not found") + + if os.path.exists(user_file): + with open(user_file) as f: + content = f.read() + new_content = st.text_area("Edit user.md", content, height=300) + if st.button("Save user.md"): + with open(user_file, "w") as f: + f.write(new_content) + st.success("Saved successfully!") + + else: + if os.path.exists(system_file): + with open(system_file) as f: + content = f.read() + + sections = content.split("#") + edited_sections = [] + + for section in sections: + if not section.strip(): + continue + + lines = section.strip().split("\n", 1) + if len(lines) > 1: + title, content = lines + else: + title, content = lines[0], "" + + st.markdown(f"#### {title}") + new_content = st.text_area( + f"Edit {title} section", + value=content.strip(), + height=200, + key=f"section_{title}" + ) + edited_sections.append(f"# {title}\n\n{new_content}") + + if st.button("Save Changes"): + new_content = "\n\n".join(edited_sections) + with open(system_file, "w") as f: + f.write(new_content) + st.success("Changes saved successfully!") + + is_valid, message = validate_pattern(pattern_name) + if not is_valid: + st.error(message) + elif message != "Pattern is valid.": + st.warning(message) + else: + st.error("system.md file not found") + +def get_outputs_dir() -> str: + """Get the directory for storing outputs.""" + outputs_dir = os.path.expanduser("~/.config/fabric/outputs") + os.makedirs(outputs_dir, exist_ok=True) + return outputs_dir + +def save_outputs(): + """Save pattern outputs and starred outputs to files. + + Error handling: + - Creates output directory if it doesn't exist + - Handles file write permissions + - Handles JSON serialization errors + - Logs all errors for debugging + """ + logger.info("Saving outputs to persistent storage") + outputs_dir = get_outputs_dir() + + output_logs_file = os.path.join(outputs_dir, "output_logs.json") + starred_outputs_file = os.path.join(outputs_dir, "starred_outputs.json") + + try: + # Save output logs + with open(output_logs_file, "w") as f: + json.dump(st.session_state.output_logs, f, indent=2) + logger.debug(f"Saved output logs to {output_logs_file}") + + # Save starred outputs + with open(starred_outputs_file, "w") as f: + json.dump(st.session_state.starred_outputs, f, indent=2) + logger.debug(f"Saved starred outputs to {starred_outputs_file}") + + except PermissionError as e: + error_msg = f"Permission denied when saving outputs: {str(e)}" + logger.error(error_msg) + st.error(error_msg) + except json.JSONEncodeError as e: + error_msg = f"Error encoding outputs to JSON: {str(e)}" + logger.error(error_msg) + st.error(error_msg) + except Exception as e: + error_msg = f"Unexpected error saving outputs: {str(e)}" + logger.error(error_msg) + st.error(error_msg) + +def load_saved_outputs(): + """Load saved pattern outputs from files. + + Error handling: + - Handles missing output files + - Handles corrupted JSON files + - Handles file read permissions + - Initializes empty state if files don't exist + """ + logger.info("Loading saved outputs") + outputs_dir = get_outputs_dir() + output_logs_file = os.path.join(outputs_dir, "output_logs.json") + starred_outputs_file = os.path.join(outputs_dir, "starred_outputs.json") + + try: + # Load output logs + if os.path.exists(output_logs_file): + with open(output_logs_file, "r") as f: + st.session_state.output_logs = json.load(f) + logger.debug(f"Loaded output logs from {output_logs_file}") + + # Load starred outputs + if os.path.exists(starred_outputs_file): + with open(starred_outputs_file, "r") as f: + st.session_state.starred_outputs = json.load(f) + logger.debug(f"Loaded starred outputs from {starred_outputs_file}") + + except json.JSONDecodeError as e: + error_msg = f"Error decoding saved outputs (corrupted files): {str(e)}" + logger.error(error_msg) + st.error(error_msg) + # Initialize empty state + st.session_state.output_logs = [] + st.session_state.starred_outputs = [] + except PermissionError as e: + error_msg = f"Permission denied when loading outputs: {str(e)}" + logger.error(error_msg) + st.error(error_msg) + except Exception as e: + error_msg = f"Unexpected error loading saved outputs: {str(e)}" + logger.error(error_msg) + st.error(error_msg) + # Initialize empty state + st.session_state.output_logs = [] + st.session_state.starred_outputs = [] + +def handle_star_name_input(log_index: int, name: str): + """Handle the starring process when a name is input. + + Args: + log_index: Index of the output to star + name: Name to give the starred output + """ + try: + if star_output(log_index, name): + st.success("Output starred successfully!") + else: + st.error("Failed to star output. Please try again.") + except Exception as e: + logger.error(f"Error handling star name input: {str(e)}") + st.error(f"Error starring output: {str(e)}") + +def execute_pattern_chain(patterns_sequence: List[str], initial_input: str) -> Dict: + """Execute a sequence of patterns in a chain, passing output from each to the next. + + Args: + patterns_sequence: List of pattern names to execute in sequence + initial_input: Initial input text to start the chain + + Returns: + Dict containing results from each stage of the chain + """ + logger.info(f"Starting pattern chain execution with {len(patterns_sequence)} patterns") + chain_results = { + "sequence": patterns_sequence, + "stages": [], + "final_output": None, + "metadata": { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "success": False + } + } + + current_input = initial_input + + try: + for i, pattern in enumerate(patterns_sequence, 1): + logger.info(f"Chain Stage {i}: Executing pattern '{pattern}'") + stage_result = { + "pattern": pattern, + "input": current_input, + "output": None, + "success": False, + "error": None + } + + try: + cmd = ["fabric", "--pattern", pattern] + result = run(cmd, input=current_input, capture_output=True, text=True, check=True) + output = result.stdout.strip() + + if output: + stage_result["output"] = output + stage_result["success"] = True + current_input = output # Use this output as input for next pattern + logger.debug(f"Stage {i} completed successfully") + else: + stage_result["error"] = "Pattern generated no output" + logger.warning(f"Pattern {pattern} generated no output") + + except CalledProcessError as e: + error_msg = f"Error executing pattern: {e.stderr.strip()}" + stage_result["error"] = error_msg + logger.error(error_msg) + break + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + stage_result["error"] = error_msg + logger.error(error_msg) + break + + chain_results["stages"].append(stage_result) + + # Save stage output to logs + save_output_log( + pattern, + stage_result["input"], + stage_result["output"] or stage_result["error"], + chain_results["metadata"]["timestamp"] + ) + + # Set final output and success status + successful_stages = [s for s in chain_results["stages"] if s["success"]] + if successful_stages: + chain_results["final_output"] = successful_stages[-1]["output"] + chain_results["metadata"]["success"] = True + + except Exception as e: + logger.error(f"Chain execution failed: {str(e)}", exc_info=True) + chain_results["metadata"]["error"] = str(e) + + return chain_results + +def enhance_input_preview(): + """Display a preview of the input content with basic statistics. + + Shows: + - Input text preview + - Character count + - Word count + """ + if 'input_content' in st.session_state and st.session_state.input_content: + with st.expander("Input Preview", expanded=True): + st.markdown("### Current Input") + st.code(st.session_state.input_content, language="text") + + # Basic statistics + char_count = len(st.session_state.input_content) + word_count = len(st.session_state.input_content.split()) + + col1, col2 = st.columns(2) + with col1: + st.metric("Characters", char_count) + with col2: + st.metric("Words", word_count) + +def get_clipboard_content() -> Tuple[bool, str, str]: + """Get content from clipboard with proper error handling. + + Returns: + Tuple[bool, str, str]: (success, content, error_message) + """ + try: + result = run( + ["xclip", "-selection", "clipboard", "-o"], + capture_output=True, + text=True, + check=True + ) + content = result.stdout + # Validate the content is proper UTF-8 + try: + content.encode('utf-8').decode('utf-8') + return True, content, "" + except UnicodeError: + return False, "", "Clipboard contains invalid Unicode characters" + except FileNotFoundError: + return False, "", "xclip is not installed. Please install it with: sudo apt-get install xclip" + except CalledProcessError as e: + return False, "", f"Failed to read clipboard: {e.stderr}" + except Exception as e: + return False, "", f"Unexpected error reading clipboard: {str(e)}" + +def set_clipboard_content(content: str) -> Tuple[bool, str]: + """Set content to clipboard with proper error handling. + + Args: + content: The content to copy to clipboard + + Returns: + Tuple[bool, str]: (success, error_message) + """ + try: + # Validate content is proper UTF-8 before attempting to copy + try: + input_bytes = content.encode('utf-8') + except UnicodeError: + return False, "Content contains invalid Unicode characters" + + run( + ["xclip", "-selection", "clipboard"], + input=input_bytes, + check=True + ) + return True, "" + except FileNotFoundError: + return False, "xclip is not installed. Please install it with: sudo apt-get install xclip" + except CalledProcessError as e: + return False, f"Failed to copy to clipboard: {e.stderr}" + except Exception as e: + return False, f"Unexpected error copying to clipboard: {str(e)}" + +def main(): + """Main function to run the Streamlit app.""" + logger.info("Starting Fabric Pattern Studio") + try: + # Set page config + st.set_page_config( + page_title="Fabric Pattern Studio", + page_icon="🧬", + layout="wide", + initial_sidebar_state="expanded" + ) + + # Add title with gradient styling and footer signature + st.markdown(""" + +
+

Pattern Studio

+
+
+
+
+ made by zo6 + """, unsafe_allow_html=True) + + initialize_session_state() + + if not st.session_state.config_loaded: + logger.info("Loading initial configuration") + success = load_configuration() + if not success: + logger.error("Failed to load configuration") + st.error("Failed to load configuration. Please check your .env file.") + st.stop() + + with st.sidebar: + # Add GitHub link + st.markdown(""" +
+ + GitHub Repo + +
+ """, + unsafe_allow_html=True + ) + + st.title("Configuration") + load_models_and_providers() + + st.markdown("---") + st.title("Navigation") + view = st.radio( + "Select View", + ["Run Patterns", "Pattern Management", "Analysis Dashboard"], + key="view_selector" + ) + logger.debug(f"Selected view: {view}") + + if view != st.session_state.get("current_view"): + st.session_state["current_view"] = view + + if view == "Run Patterns": + patterns = get_patterns() + logger.debug(f"Available patterns: {patterns}") + + if not patterns: + logger.warning("No patterns available") + st.warning("No patterns available. Create a pattern first.") + return + + tabs = st.tabs(["Run", "Analysis"]) + + with tabs[0]: + st.header("Run Patterns") + selected_patterns = st.multiselect( + "Select Patterns to Run", + patterns, + default=st.session_state.selected_patterns, + key="selected_patterns_widget" + ) + st.session_state.selected_patterns = selected_patterns + + if selected_patterns: + for pattern in selected_patterns: + with st.expander(f"πŸ“ {pattern} Details", expanded=False): + metadata = get_pattern_metadata(pattern) + if metadata: + st.markdown(metadata) + else: + st.info("No description available") + + st.subheader("Input") + input_method = st.radio( + "Input Method", + ["Clipboard", "Manual Input"], + horizontal=True + ) + + if input_method == "Clipboard": + col_load, col_preview = st.columns([2, 1]) + with col_load: + if st.button("πŸ“‹ Load from Clipboard", use_container_width=True): + success, content, error = get_clipboard_content() + if success: + # Validate clipboard content + is_valid, error_message = validate_input_content(content) + if not is_valid: + st.error(f"Invalid clipboard content: {error_message}") + else: + # Sanitize clipboard content + sanitized_content = sanitize_input_content(content) + if sanitized_content != content: + st.warning("Clipboard content was automatically sanitized for better compatibility.") + + st.session_state.input_content = sanitized_content + st.session_state.show_preview = True + st.success("Content loaded from clipboard!") + else: + st.error(error) + + with col_preview: + if st.button("πŸ‘ Toggle Preview", use_container_width=True): + st.session_state.show_preview = not st.session_state.get('show_preview', False) + else: + st.session_state.input_content = st.text_area( + "Enter Input Text", + value=st.session_state.get('input_content', ''), + height=200 + ) + + if st.session_state.get('show_preview', False) or input_method == "Manual Input": + if st.session_state.get('input_content'): + enhance_input_preview() + + # Move chain mode checkbox before the run button + chain_mode = st.checkbox( + "Chain Mode", + help="Execute patterns in sequence, passing output of each pattern as input to the next" + ) + + if chain_mode and len(selected_patterns) > 1: + st.info("Patterns will be executed in the order selected above") + st.markdown("##### Drag to reorder patterns:") + # Convert patterns list to DataFrame for data editor + patterns_df = pd.DataFrame({ + "Pattern": selected_patterns + }) + + edited_df = st.data_editor( + patterns_df, + use_container_width=True, + key="pattern_reorder", + hide_index=True, + column_config={ + "Pattern": st.column_config.TextColumn( + "Pattern", + help="Drag to reorder patterns" + ) + } + ) + + # Update selected patterns if order changed + new_patterns = edited_df["Pattern"].tolist() + if new_patterns != selected_patterns: + st.session_state.selected_patterns = new_patterns + + col1, col2 = st.columns([3, 1]) + with col1: + if st.button("πŸš€ Run Patterns", type="primary", use_container_width=True): + if not st.session_state.input_content: + st.warning("Please provide input content.") + else: + with st.spinner("Running patterns..."): + if chain_mode: + # Execute pattern chain + chain_results = execute_pattern_chain( + selected_patterns, + st.session_state.input_content + ) + + # Display chain results + st.markdown("## Chain Execution Results") + + # Show sequence + st.markdown("### Pattern Sequence") + st.code(" β†’ ".join(chain_results["sequence"])) + + # Show each stage + st.markdown("### Execution Stages") + for i, stage in enumerate(chain_results["stages"], 1): + with st.expander(f"Stage {i}: {stage['pattern']}", expanded=False): + st.markdown("#### Input") + st.code(stage["input"]) + st.markdown("#### Output") + if stage["success"]: + st.markdown(stage["output"]) + else: + st.error(stage["error"]) + + # Show final output + if chain_results["metadata"]["success"]: + st.markdown("### Final Output") + st.markdown(chain_results["final_output"]) + st.session_state.chat_output.append(chain_results["final_output"]) + else: + st.error("Chain execution failed. Check the stages above for details.") + else: + # Normal pattern execution + outputs = execute_patterns(selected_patterns) + st.session_state.chat_output.extend(outputs) + + # Display outputs after execution + if st.session_state.chat_output: + st.markdown("---") + st.header("Pattern Outputs") + for message in st.session_state.chat_output: + st.markdown(message) + st.markdown("---") # Add separator between outputs + + # Output Actions + col1, col2 = st.columns(2) + with col1: + if st.button("πŸ“‹ Copy All Outputs"): + all_outputs = "\n\n".join(st.session_state.chat_output) + success, error = set_clipboard_content(all_outputs) + if success: + st.success("All outputs copied to clipboard!") + else: + st.error(error) + + with col2: + if st.button("❌ Clear Outputs"): + st.session_state.chat_output = [] + st.success("Outputs cleared!") + st.experimental_rerun() + + with col2: + st.write("") # Empty space for layout balance + + else: + st.info("Select one or more patterns to run.") + + with tabs[1]: + st.header("Output Analysis") + if st.session_state.chat_output: + # Display pattern outputs in chronological order + for i, output in enumerate(reversed(st.session_state.chat_output), 1): + with st.expander(f"Output #{i}", expanded=False): + st.markdown(output) + else: + st.info("Run some patterns to see output analysis.") + + elif view == "Pattern Management": + create_tab, edit_tab, delete_tab = st.tabs(["Create", "Edit", "Delete"]) + + with create_tab: + st.header("Create New Pattern") + creation_mode = st.radio( + "Creation Mode", + ["Simple Editor", "Advanced (Wizard)"], + key="creation_mode_main", + horizontal=True + ) + + if creation_mode == "Simple Editor": + pattern_creation_ui() + else: + pattern_creation_wizard() + + with edit_tab: + st.header("Edit Patterns") + patterns = get_patterns() + if not patterns: + st.warning("No patterns available. Create a pattern first.") + else: + selected_pattern = st.selectbox("Select Pattern to Edit", [""] + patterns) + if selected_pattern: + pattern_editor(selected_pattern) + + with delete_tab: + st.header("Delete Patterns") + patterns = get_patterns() + if not patterns: + st.warning("No patterns available.") + else: + patterns_to_delete = st.multiselect( + "Select Patterns to Delete", + patterns, + key="delete_patterns_selector" + ) + + if patterns_to_delete: + st.warning(f"You are about to delete {len(patterns_to_delete)} pattern(s):") + for pattern in patterns_to_delete: + st.markdown(f"- {pattern}") + + confirm_delete = st.checkbox("I understand that this action cannot be undone") + + if st.button("πŸ—‘οΈ Delete Selected Patterns", type="primary", disabled=not confirm_delete): + if confirm_delete: + for pattern in patterns_to_delete: + success, message = delete_pattern(pattern) + if success: + st.success(f"βœ“ {pattern}: {message}") + else: + st.error(f"βœ— {pattern}: {message}") + st.experimental_rerun() + else: + st.error("Please confirm deletion by checking the box above.") + else: + st.info("Select one or more patterns to delete.") + + else: + st.header("Pattern Output History") + + # Create tabs for All Outputs and Starred Outputs + all_tab, starred_tab = st.tabs(["All Outputs", "⭐ Starred"]) + + with all_tab: + if not st.session_state.output_logs: + st.info("No pattern outputs recorded yet. Run some patterns to see their logs here.") + else: + for i, log in enumerate(reversed(st.session_state.output_logs)): + with st.expander( + f"Output #{len(st.session_state.output_logs)-i} - {log['pattern_name']} ({log['timestamp']})", + expanded=False + ): + st.markdown("### Input") + st.code(log["input"], language="text") + st.markdown("### Output") + st.markdown(log["output"]) + + # Check if this output is already starred + is_starred = any(s["timestamp"] == log["timestamp"] for s in st.session_state.starred_outputs) + + col1, col2 = st.columns([1, 4]) + with col1: + if not is_starred: + if st.button("⭐ Star", key=f"star_{i}", use_container_width=True): + st.session_state.starring_output = len(st.session_state.output_logs) - i - 1 + st.session_state.temp_star_name = "" + else: + st.write("⭐ Starred") + + with col2: + if st.button("πŸ“‹ Copy Output", key=f"copy_{i}"): + success, error = set_clipboard_content(log["output"]) + if success: + st.success("Output copied to clipboard!") + else: + st.error(error) + + # Show starring form inside the expander if this is the output being starred + if st.session_state.starring_output == len(st.session_state.output_logs) - i - 1: + st.markdown("---") + with st.form(key=f"star_name_form_{i}"): + name_input = st.text_input( + "Enter a name for this output (optional):", + key=f"star_name_input_{i}" + ) + col1, col2 = st.columns(2) + with col1: + submit = st.form_submit_button("Save", use_container_width=True) + with col2: + cancel = st.form_submit_button("Cancel", use_container_width=True) + + if submit: + handle_star_name_input(st.session_state.starring_output, name_input) + # Reset starring state after handling + st.session_state.starring_output = None + st.experimental_rerun() + elif cancel: + # Reset starring state + st.session_state.starring_output = None + st.experimental_rerun() + + # Remove the old starring form from the bottom + st.markdown("---") + + with starred_tab: + if not st.session_state.starred_outputs: + st.info("No starred outputs yet. Star some outputs to see them here!") + else: + for i, starred in enumerate(st.session_state.starred_outputs): + with st.expander( + f"⭐ {starred.get('custom_name', f'Starred Output #{i+1}')} ({starred['timestamp']})", + expanded=False + ): + col1, col2 = st.columns([3, 1]) + with col1: + st.markdown(f"### {starred.get('custom_name', f'Starred Output #{i+1}')}") + with col2: + if st.button("✏️ Edit Name", key=f"edit_name_{i}"): + st.session_state[f"editing_name_{i}"] = True + + if st.session_state.get(f"editing_name_{i}", False): + new_name = st.text_input( + "Enter new name:", + value=starred.get('custom_name', ''), + key=f"new_name_{i}" + ) + col1, col2 = st.columns([1, 1]) + with col1: + if st.button("Save", key=f"save_name_{i}"): + st.session_state.starred_outputs[i]['custom_name'] = new_name + del st.session_state[f"editing_name_{i}"] + st.success("Name updated!") + st.experimental_rerun() + with col2: + if st.button("Cancel", key=f"cancel_name_{i}"): + del st.session_state[f"editing_name_{i}"] + st.experimental_rerun() + + st.markdown("### Pattern") + st.code(starred["pattern_name"], language="text") + st.markdown("### Input") + st.code(starred["input"], language="text") # Display input as code block + st.markdown("### Output") + st.markdown(starred["output"]) # Display output as markdown + + col1, col2 = st.columns([1, 4]) + with col1: + if st.button("❌ Remove Star", key=f"unstar_{i}"): + unstar_output(i) + st.success("Output unstarred!") + st.experimental_rerun() + + with col2: + if st.button("πŸ“‹ Copy Output", key=f"copy_starred_{i}"): + try: + run(["xclip", "-selection", "clipboard"], input=starred["output"].encode(), check=True) + st.success("Output copied to clipboard!") + except Exception as e: + st.error(f"Error copying to clipboard: {e}") + + if st.button("Clear All Starred"): + if st.checkbox("Confirm clearing all starred outputs"): + st.session_state.starred_outputs = [] + save_outputs() # Save after clearing + st.success("All starred outputs cleared!") + st.experimental_rerun() + + except Exception as e: + logger.error("Unexpected error in main function", exc_info=True) + st.error(f"An unexpected error occurred: {str(e)}") + st.stop() + +if __name__ == "__main__": + logger.info("Application startup") + main() \ No newline at end of file diff --git a/version.go b/version.go index bdb7b1878..aa41f9451 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -var version = "v1.4.123" +var version = "v1.4.128"