From 733aa231dab02e06964523cf1b111f05b32dae7e Mon Sep 17 00:00:00 2001 From: Aaron Chantrill Date: Thu, 21 Aug 2025 11:51:32 -0400 Subject: [PATCH 1/5] First request to ChatGPT Here was my first request to ChatGPT to get it to generate a plugin generator specifically for generating speechhandler plugins in the Naomi style: "I would love your help in creating a tool in python to help people create new speechhandler plugins for Naomi. Here is an example speechhandler plugin: https://github.com/aaronchantrill/NaomiNetworkCheck "One of the big issues with the Naomi project is making it easy for people to register their plugins with the project. Right now, you have to create a plugin.info file with your plugin that includes the url of the repository that will be hosting it (on github, gitlab, etc.) then when you "publish" your plugin, you have to create a new pull request where you insert a new line into the CSV file at https://github.com/NaomiProject/naomi-plugins/blob/master/plugins.csv which includes a lot of the information in the plugin.info file, plus the actual commit signature of the current version. This information is hard to synchronize. What I would like would be a GUI for adding a bunch of intent keywords and templates, then the name, description and license to generate a simple plugin that just repeats back what it hears. The user then edits the python in whatever ide they like, then when they are ready to publish, they reopen the GUI, select the project, and hit a publish button. This will automatically collect the information from the plugin.info file, push the current version to github, then make a pull request with the new line in the CSV file. Does that make sense?" This pull request is what ChatGPT first came up with. I wasn't specific about what packages to use for the GUI part, both because I assumed it would use something normal like tkinter and because I was interested to see what it would choose if it chose something else. It chose to use PySimpleGUI. When I tried to pip install it, I got a EULA informing me that the package requires registration and for non-hobbyist users it also requires a paid subscription. This was a non-starter for me since I can imagine that business users might want to create plugins, and forcing them to license a Python package due to an arbitrary choice by ChatGPT just didn't seem reasonable. Requiring users to register is also problematic for me from a privacy perspective. It looks like PySimpleGUI is in the process of shutting down anyway. I never actually tried to run this version. shutting down completely anyway. --- gui.py | 55 +++++++++++++++++++++++ main.py | 32 +++++++++++++ plugin_generator.py | 64 ++++++++++++++++++++++++++ publisher.py | 74 +++++++++++++++++++++++++++++++ requirements.txt | 3 ++ templates/plugin_info_template.py | 8 ++++ templates/plugin_init_template.py | 13 ++++++ utils.py | 7 +++ 8 files changed, 256 insertions(+) create mode 100644 gui.py create mode 100644 main.py create mode 100644 plugin_generator.py create mode 100644 publisher.py create mode 100644 requirements.txt create mode 100644 templates/plugin_info_template.py create mode 100644 templates/plugin_init_template.py create mode 100644 utils.py diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..9a5d706 --- /dev/null +++ b/gui.py @@ -0,0 +1,55 @@ +import PySimpleGUI as sg +from plugin_generator import generate_plugin +from publisher import publish_plugin + +def start_gui(): + sg.theme("DarkBlue3") + + tab1_layout = [ + [sg.Text("Plugin Name"), sg.Input(key="-NAME-")], + [sg.Text("Description"), sg.Input(key="-DESC-")], + [sg.Text("License"), sg.Input(key="-LICENSE-")], + [sg.Text("Keywords (comma-separated)"), sg.Input(key="-KEYWORDS-")], + [sg.Text("Templates (comma-separated)"), sg.Input(key="-TEMPLATES-")], + [sg.Text("Output Directory"), sg.Input(key="-OUTDIR-"), sg.FolderBrowse()], + [sg.Button("Generate Plugin")] + ] + + tab2_layout = [ + [sg.Text("Plugin Folder"), sg.Input(key="-PLUGINPATH-"), sg.FolderBrowse()], + [sg.Text("GitHub Username"), sg.Input(key="-GHUSER-")], + [sg.Text("GitHub Token"), sg.Input(key="-GHTOKEN-", password_char="*")], + [sg.Button("Publish Plugin")] + ] + + layout = [[sg.TabGroup([ + [sg.Tab("Create Plugin", tab1_layout), sg.Tab("Publish Plugin", tab2_layout)] + ])]] + + window = sg.Window("Naomi Plugin Tool", layout) + + while True: + event, values = window.read() + if event in (sg.WINDOW_CLOSED, "Exit"): + break + + if event == "Generate Plugin": + path = generate_plugin( + values["-NAME-"], + values["-DESC-"], + values["-LICENSE-"], + values["-KEYWORDS-"].split(","), + values["-TEMPLATES-"].split(","), + values["-OUTDIR-"] + ) + sg.popup("Plugin created!", f"Path: {path}") + + if event == "Publish Plugin": + url = publish_plugin( + values["-PLUGINPATH-"], + values["-GHUSER-"], + values["-GHTOKEN-"] + ) + sg.popup("Plugin published!", f"Pull Request URL: {url}") + + window.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..4a6db6d --- /dev/null +++ b/main.py @@ -0,0 +1,32 @@ +# gui.py +import PySimpleGUI as sg +from plugin_generator import create_plugin_skeleton + +def run(): + sg.theme("DarkBlue3") + + layout = [ + [sg.Text("Naomi Plugin Creator", font=("Helvetica", 16))], + [sg.Text("Plugin Name"), sg.Input(key="name")], + [sg.Text("Description"), sg.Multiline(size=(40, 4), key="description")], + [sg.Text("License"), sg.Input(key="license", default_text="MIT")], + [sg.Text("Intent Keywords (comma-separated)"), sg.Input(key="keywords")], + [sg.Button("Create Plugin"), sg.Button("Exit")] + ] + + window = sg.Window("Naomi Plugin Creator", layout) + + while True: + event, values = window.read() + if event in (sg.WIN_CLOSED, "Exit"): + break + elif event == "Create Plugin": + create_plugin_skeleton( + name=values["name"], + description=values["description"], + license_name=values["license"], + keywords=[kw.strip() for kw in values["keywords"].split(",") if kw.strip()] + ) + sg.popup("Plugin created successfully!") + + window.close() diff --git a/plugin_generator.py b/plugin_generator.py new file mode 100644 index 0000000..d78d2db --- /dev/null +++ b/plugin_generator.py @@ -0,0 +1,64 @@ +import os +from pathlib import Path +from utils import load_template, save_file + +def generate_plugin(name, description, license, keywords, templates, output_dir): + class_name = ''.join(word.capitalize() for word in name.split()) + folder_path = Path(output_dir) / name + folder_path.mkdir(parents=True, exist_ok=True) + + # Create __init__.py + init_code = load_template("templates/plugin_init_template.py").format( + PLUGIN_CLASS=class_name, + PLUGIN_NAME=name.lower(), + KEYWORDS=keywords, + TEMPLATES=templates + ) + save_file(folder_path / "__init__.py", init_code) + + # Create plugin.info + plugin_info = load_template("templates/plugin_info_template.txt").format( + PLUGIN_NAME=name, + DESCRIPTION=description, + LICENSE=license, + PLUGIN_URL="https://github.com/yourusername/" + name + ) + save_file(folder_path / "plugin.info", plugin_info) + + # Create README.md + save_file(folder_path / "README.md", f"# {name}\n\n{description}") + + return folder_path + +def create_plugin_skeleton(name, description, license_name, keywords): + folder = name.replace(" ", "_") + os.makedirs(folder, exist_ok=True) + + # Create plugin.info + with open(os.path.join(folder, "plugin.info"), "w") as f: + f.write(f"name={name}\n") + f.write(f"description={description.strip()}\n") + f.write(f"license={license_name}\n") + f.write(f"keywords={','.join(keywords)}\n") + + # Create __init__.py + with open(os.path.join(folder, "__init__.py"), "w") as f: + f.write("# Naomi speechhandler plugin\n") + + # Create main handler file + handler_name = name.replace(" ", "") + "Handler.py" + with open(os.path.join(folder, handler_name), "w") as f: + f.write(f'''from naomi import plugin + +class {name.replace(" ", "")}Handler(plugin.SpeechHandlerPlugin): + def intents(self): + return {{ + "RepeatIntent": {{ + "keywords": {keywords} + }} + }} + + def handle(self, text, intent): + self.say(text) +''') + diff --git a/publisher.py b/publisher.py new file mode 100644 index 0000000..c360c60 --- /dev/null +++ b/publisher.py @@ -0,0 +1,74 @@ +import csv +import tempfile +from github import Github +from pathlib import Path +import git + +CSV_URL = "https://github.com/NaomiProject/naomi-plugins/blob/master/plugins.csv" + +def publish_plugin(plugin_path, username, token): + plugin_info_file = Path(plugin_path) / "plugin.info" + if not plugin_info_file.exists(): + raise FileNotFoundError("plugin.info not found in the plugin folder.") + + # Load plugin.info into dict + info = {} + with open(plugin_info_file) as f: + for line in f: + if "=" in line: + k, v = line.strip().split("=", 1) + info[k] = v + + repo_name = info["name"] + repo_url = f"https://github.com/{username}/{repo_name}" + + # Push plugin folder to GitHub + local_repo = git.Repo.init(plugin_path) + local_repo.git.add(A=True) + local_repo.index.commit("Initial commit") + origin = local_repo.create_remote('origin', repo_url) + origin.push(refspec='master:master') + + # Authenticate to GitHub + g = Github(token) + repo = g.get_repo("NaomiProject/naomi-plugins") + + # Download CSV and add new entry + csv_path = tempfile.mktemp(suffix=".csv") + repo_csv = repo.get_contents("plugins.csv") + with open(csv_path, "w", newline="") as f: + f.write(repo_csv.decoded_content.decode()) + + with open(csv_path, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + info["name"], + info["description"], + info["license"], + repo_url, + "speechhandler", + "0.0.1", + local_repo.head.commit.hexsha + ]) + + # Create new branch and PR + branch_name = f"add-{repo_name}-plugin" + base = repo.get_branch("master") + repo.create_git_ref(ref=f"refs/heads/{branch_name}", sha=base.commit.sha) + + repo.update_file( + path="plugins.csv", + message=f"Add {repo_name} plugin", + content=open(csv_path).read(), + sha=repo_csv.sha, + branch=branch_name + ) + + pr = repo.create_pull( + title=f"Add {repo_name} plugin", + body=f"Adding {repo_name} to plugins.csv", + head=branch_name, + base="master" + ) + + return pr.html_url diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f17cd1f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PySimpleGUI +PyGithub +gitpython diff --git a/templates/plugin_info_template.py b/templates/plugin_info_template.py new file mode 100644 index 0000000..10e96fb --- /dev/null +++ b/templates/plugin_info_template.py @@ -0,0 +1,8 @@ +[plugin] +name={PLUGIN_NAME} +description={DESCRIPTION} +license={LICENSE} +type=speechhandler +version=0.0.1 +url={PLUGIN_URL} + diff --git a/templates/plugin_init_template.py b/templates/plugin_init_template.py new file mode 100644 index 0000000..3825a78 --- /dev/null +++ b/templates/plugin_init_template.py @@ -0,0 +1,13 @@ +from naomi.plugin import SpeechHandlerPlugin + +class {PLUGIN_CLASS}(SpeechHandlerPlugin): + def intents(self): + return { + "{PLUGIN_NAME}Intent": { + "keywords": {KEYWORDS}, + "templates": {TEMPLATES} + } + } + + def handle(self, intent, mic): + mic.say(f"You said: {intent.get('text', '')}") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..584a3d6 --- /dev/null +++ b/utils.py @@ -0,0 +1,7 @@ +def load_template(path): + with open(path, "r") as f: + return f.read() + +def save_file(path, content): + with open(path, "w") as f: + f.write(content) From 9e4eb56116a41110158f82dfc106e8752d03518e Mon Sep 17 00:00:00 2001 From: Aaron Chantrill Date: Thu, 21 Aug 2025 12:51:17 -0400 Subject: [PATCH 2/5] Second request to ChatGPT Here is my next request: "I don't want to use PySimpleGUI since it would require my users to license their package. Could you write the GUI in tkinter instead?" and ChatGPT's response is what I am committing. This version ran, but it was obvious that ChatGPT did not understand the actual structure of a Naomi intent. --- gui.py | 106 ++++++++++++++---------------- main.py | 35 ++-------- plugin_generator.py | 48 +++----------- publisher.py | 74 --------------------- requirements.txt | 3 - templates/plugin_info_template.py | 8 --- templates/plugin_init_template.py | 13 ---- utils.py | 7 -- 8 files changed, 64 insertions(+), 230 deletions(-) delete mode 100644 publisher.py delete mode 100644 requirements.txt delete mode 100644 templates/plugin_info_template.py delete mode 100644 templates/plugin_init_template.py delete mode 100644 utils.py diff --git a/gui.py b/gui.py index 9a5d706..8e530f7 100644 --- a/gui.py +++ b/gui.py @@ -1,55 +1,51 @@ -import PySimpleGUI as sg -from plugin_generator import generate_plugin -from publisher import publish_plugin - -def start_gui(): - sg.theme("DarkBlue3") - - tab1_layout = [ - [sg.Text("Plugin Name"), sg.Input(key="-NAME-")], - [sg.Text("Description"), sg.Input(key="-DESC-")], - [sg.Text("License"), sg.Input(key="-LICENSE-")], - [sg.Text("Keywords (comma-separated)"), sg.Input(key="-KEYWORDS-")], - [sg.Text("Templates (comma-separated)"), sg.Input(key="-TEMPLATES-")], - [sg.Text("Output Directory"), sg.Input(key="-OUTDIR-"), sg.FolderBrowse()], - [sg.Button("Generate Plugin")] - ] - - tab2_layout = [ - [sg.Text("Plugin Folder"), sg.Input(key="-PLUGINPATH-"), sg.FolderBrowse()], - [sg.Text("GitHub Username"), sg.Input(key="-GHUSER-")], - [sg.Text("GitHub Token"), sg.Input(key="-GHTOKEN-", password_char="*")], - [sg.Button("Publish Plugin")] - ] - - layout = [[sg.TabGroup([ - [sg.Tab("Create Plugin", tab1_layout), sg.Tab("Publish Plugin", tab2_layout)] - ])]] - - window = sg.Window("Naomi Plugin Tool", layout) - - while True: - event, values = window.read() - if event in (sg.WINDOW_CLOSED, "Exit"): - break - - if event == "Generate Plugin": - path = generate_plugin( - values["-NAME-"], - values["-DESC-"], - values["-LICENSE-"], - values["-KEYWORDS-"].split(","), - values["-TEMPLATES-"].split(","), - values["-OUTDIR-"] - ) - sg.popup("Plugin created!", f"Path: {path}") - - if event == "Publish Plugin": - url = publish_plugin( - values["-PLUGINPATH-"], - values["-GHUSER-"], - values["-GHTOKEN-"] - ) - sg.popup("Plugin published!", f"Pull Request URL: {url}") - - window.close() +# gui.py +import tkinter as tk +from tkinter import messagebox, scrolledtext +from plugin_generator import create_plugin_skeleton + +def run(): + root = tk.Tk() + root.title("Naomi Plugin Creator") + + # Title + tk.Label(root, text="Naomi Plugin Creator", font=("Helvetica", 16, "bold")).grid(row=0, column=0, columnspan=2, pady=10) + + # Plugin Name + tk.Label(root, text="Plugin Name").grid(row=1, column=0, sticky="w") + entry_name = tk.Entry(root, width=40) + entry_name.grid(row=1, column=1, pady=2) + + # Description + tk.Label(root, text="Description").grid(row=2, column=0, sticky="nw") + text_desc = scrolledtext.ScrolledText(root, width=30, height=4) + text_desc.grid(row=2, column=1, pady=2) + + # License + tk.Label(root, text="License").grid(row=3, column=0, sticky="w") + entry_license = tk.Entry(root, width=40) + entry_license.insert(0, "MIT") + entry_license.grid(row=3, column=1, pady=2) + + # Keywords + tk.Label(root, text="Intent Keywords (comma-separated)").grid(row=4, column=0, sticky="w") + entry_keywords = tk.Entry(root, width=40) + entry_keywords.grid(row=4, column=1, pady=2) + + # Actions + def on_create(): + name = entry_name.get().strip() + description = text_desc.get("1.0", tk.END).strip() + license_name = entry_license.get().strip() + keywords = [kw.strip() for kw in entry_keywords.get().split(",") if kw.strip()] + + if not name: + messagebox.showerror("Error", "Plugin name is required.") + return + + create_plugin_skeleton(name, description, license_name, keywords) + messagebox.showinfo("Success", f"Plugin '{name}' created successfully!") + + tk.Button(root, text="Create Plugin", command=on_create).grid(row=5, column=0, pady=10) + tk.Button(root, text="Exit", command=root.destroy).grid(row=5, column=1, pady=10) + + root.mainloop() diff --git a/main.py b/main.py index 4a6db6d..4b7d99c 100644 --- a/main.py +++ b/main.py @@ -1,32 +1,5 @@ -# gui.py -import PySimpleGUI as sg -from plugin_generator import create_plugin_skeleton +# main.py +import gui -def run(): - sg.theme("DarkBlue3") - - layout = [ - [sg.Text("Naomi Plugin Creator", font=("Helvetica", 16))], - [sg.Text("Plugin Name"), sg.Input(key="name")], - [sg.Text("Description"), sg.Multiline(size=(40, 4), key="description")], - [sg.Text("License"), sg.Input(key="license", default_text="MIT")], - [sg.Text("Intent Keywords (comma-separated)"), sg.Input(key="keywords")], - [sg.Button("Create Plugin"), sg.Button("Exit")] - ] - - window = sg.Window("Naomi Plugin Creator", layout) - - while True: - event, values = window.read() - if event in (sg.WIN_CLOSED, "Exit"): - break - elif event == "Create Plugin": - create_plugin_skeleton( - name=values["name"], - description=values["description"], - license_name=values["license"], - keywords=[kw.strip() for kw in values["keywords"].split(",") if kw.strip()] - ) - sg.popup("Plugin created successfully!") - - window.close() +if __name__ == "__main__": + gui.run() diff --git a/plugin_generator.py b/plugin_generator.py index d78d2db..4236dc7 100644 --- a/plugin_generator.py +++ b/plugin_generator.py @@ -1,53 +1,24 @@ +# plugin_generator.py import os -from pathlib import Path -from utils import load_template, save_file - -def generate_plugin(name, description, license, keywords, templates, output_dir): - class_name = ''.join(word.capitalize() for word in name.split()) - folder_path = Path(output_dir) / name - folder_path.mkdir(parents=True, exist_ok=True) - - # Create __init__.py - init_code = load_template("templates/plugin_init_template.py").format( - PLUGIN_CLASS=class_name, - PLUGIN_NAME=name.lower(), - KEYWORDS=keywords, - TEMPLATES=templates - ) - save_file(folder_path / "__init__.py", init_code) - - # Create plugin.info - plugin_info = load_template("templates/plugin_info_template.txt").format( - PLUGIN_NAME=name, - DESCRIPTION=description, - LICENSE=license, - PLUGIN_URL="https://github.com/yourusername/" + name - ) - save_file(folder_path / "plugin.info", plugin_info) - - # Create README.md - save_file(folder_path / "README.md", f"# {name}\n\n{description}") - - return folder_path def create_plugin_skeleton(name, description, license_name, keywords): folder = name.replace(" ", "_") os.makedirs(folder, exist_ok=True) - # Create plugin.info - with open(os.path.join(folder, "plugin.info"), "w") as f: + # plugin.info + with open(os.path.join(folder, "plugin.info"), "w", encoding="utf-8") as f: f.write(f"name={name}\n") - f.write(f"description={description.strip()}\n") + f.write(f"description={description}\n") f.write(f"license={license_name}\n") f.write(f"keywords={','.join(keywords)}\n") - # Create __init__.py - with open(os.path.join(folder, "__init__.py"), "w") as f: + # __init__.py + with open(os.path.join(folder, "__init__.py"), "w", encoding="utf-8") as f: f.write("# Naomi speechhandler plugin\n") - # Create main handler file - handler_name = name.replace(" ", "") + "Handler.py" - with open(os.path.join(folder, handler_name), "w") as f: + # Handler file + handler_filename = name.replace(" ", "") + "Handler.py" + with open(os.path.join(folder, handler_filename), "w", encoding="utf-8") as f: f.write(f'''from naomi import plugin class {name.replace(" ", "")}Handler(plugin.SpeechHandlerPlugin): @@ -61,4 +32,3 @@ def intents(self): def handle(self, text, intent): self.say(text) ''') - diff --git a/publisher.py b/publisher.py deleted file mode 100644 index c360c60..0000000 --- a/publisher.py +++ /dev/null @@ -1,74 +0,0 @@ -import csv -import tempfile -from github import Github -from pathlib import Path -import git - -CSV_URL = "https://github.com/NaomiProject/naomi-plugins/blob/master/plugins.csv" - -def publish_plugin(plugin_path, username, token): - plugin_info_file = Path(plugin_path) / "plugin.info" - if not plugin_info_file.exists(): - raise FileNotFoundError("plugin.info not found in the plugin folder.") - - # Load plugin.info into dict - info = {} - with open(plugin_info_file) as f: - for line in f: - if "=" in line: - k, v = line.strip().split("=", 1) - info[k] = v - - repo_name = info["name"] - repo_url = f"https://github.com/{username}/{repo_name}" - - # Push plugin folder to GitHub - local_repo = git.Repo.init(plugin_path) - local_repo.git.add(A=True) - local_repo.index.commit("Initial commit") - origin = local_repo.create_remote('origin', repo_url) - origin.push(refspec='master:master') - - # Authenticate to GitHub - g = Github(token) - repo = g.get_repo("NaomiProject/naomi-plugins") - - # Download CSV and add new entry - csv_path = tempfile.mktemp(suffix=".csv") - repo_csv = repo.get_contents("plugins.csv") - with open(csv_path, "w", newline="") as f: - f.write(repo_csv.decoded_content.decode()) - - with open(csv_path, "a", newline="") as f: - writer = csv.writer(f) - writer.writerow([ - info["name"], - info["description"], - info["license"], - repo_url, - "speechhandler", - "0.0.1", - local_repo.head.commit.hexsha - ]) - - # Create new branch and PR - branch_name = f"add-{repo_name}-plugin" - base = repo.get_branch("master") - repo.create_git_ref(ref=f"refs/heads/{branch_name}", sha=base.commit.sha) - - repo.update_file( - path="plugins.csv", - message=f"Add {repo_name} plugin", - content=open(csv_path).read(), - sha=repo_csv.sha, - branch=branch_name - ) - - pr = repo.create_pull( - title=f"Add {repo_name} plugin", - body=f"Adding {repo_name} to plugins.csv", - head=branch_name, - base="master" - ) - - return pr.html_url diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f17cd1f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -PySimpleGUI -PyGithub -gitpython diff --git a/templates/plugin_info_template.py b/templates/plugin_info_template.py deleted file mode 100644 index 10e96fb..0000000 --- a/templates/plugin_info_template.py +++ /dev/null @@ -1,8 +0,0 @@ -[plugin] -name={PLUGIN_NAME} -description={DESCRIPTION} -license={LICENSE} -type=speechhandler -version=0.0.1 -url={PLUGIN_URL} - diff --git a/templates/plugin_init_template.py b/templates/plugin_init_template.py deleted file mode 100644 index 3825a78..0000000 --- a/templates/plugin_init_template.py +++ /dev/null @@ -1,13 +0,0 @@ -from naomi.plugin import SpeechHandlerPlugin - -class {PLUGIN_CLASS}(SpeechHandlerPlugin): - def intents(self): - return { - "{PLUGIN_NAME}Intent": { - "keywords": {KEYWORDS}, - "templates": {TEMPLATES} - } - } - - def handle(self, intent, mic): - mic.say(f"You said: {intent.get('text', '')}") diff --git a/utils.py b/utils.py deleted file mode 100644 index 584a3d6..0000000 --- a/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -def load_template(path): - with open(path, "r") as f: - return f.read() - -def save_file(path, content): - with open(path, "w") as f: - f.write(content) From cca8905d5f38d8ae1e604f42c0949a52946b9084 Mon Sep 17 00:00:00 2001 From: Aaron Chantrill Date: Thu, 21 Aug 2025 12:58:35 -0400 Subject: [PATCH 3/5] Third request to ChatGPT Here is my third request to ChatGPT: A Naomi intent needs to have both templates and keywords. Right now you only have the keywords, and that is as a comma separated list. The keywords actually need to be a list of dicts that contain lists. I think the example I gave you before was too simple. Here's a better example which has a more complete intents structure: https://github.com/aaronchantrill/naomi_nws_weather.git The response is this version. ChatGPT did provide spaces for entering an intent name, a comma-separated list of templates and a comma-separated list of keywords, although it did not get how the keywords are meant to be used. --- gui.py | 73 ++++++++++++++++++++++++++---------------- main.py | 5 ++- plugin_generator.py | 78 +++++++++++++++++++++++++++++---------------- utils.py | 5 +++ 4 files changed, 103 insertions(+), 58 deletions(-) create mode 100644 utils.py diff --git a/gui.py b/gui.py index 8e530f7..d299d28 100644 --- a/gui.py +++ b/gui.py @@ -1,4 +1,3 @@ -# gui.py import tkinter as tk from tkinter import messagebox, scrolledtext from plugin_generator import create_plugin_skeleton @@ -7,45 +6,65 @@ def run(): root = tk.Tk() root.title("Naomi Plugin Creator") - # Title - tk.Label(root, text="Naomi Plugin Creator", font=("Helvetica", 16, "bold")).grid(row=0, column=0, columnspan=2, pady=10) - - # Plugin Name - tk.Label(root, text="Plugin Name").grid(row=1, column=0, sticky="w") + # Plugin metadata + tk.Label(root, text="Plugin Name").grid(row=0, column=0, sticky="w") entry_name = tk.Entry(root, width=40) - entry_name.grid(row=1, column=1, pady=2) + entry_name.grid(row=0, column=1, pady=2) + + tk.Label(root, text="Description").grid(row=1, column=0, sticky="nw") + text_desc = scrolledtext.ScrolledText(root, width=30, height=3) + text_desc.grid(row=1, column=1, pady=2) + + tk.Label(root, text="License").grid(row=2, column=0, sticky="w") + entry_license = tk.Entry(root, width=40); entry_license.insert(0, "MIT") + entry_license.grid(row=2, column=1, pady=2) + + # Intent input + intents_frame = tk.Frame(root) + intents_frame.grid(row=3, column=0, columnspan=2, pady=10, sticky="w") + + tk.Label(intents_frame, text="Intents:").grid(row=0, column=0, sticky="w") - # Description - tk.Label(root, text="Description").grid(row=2, column=0, sticky="nw") - text_desc = scrolledtext.ScrolledText(root, width=30, height=4) - text_desc.grid(row=2, column=1, pady=2) + intent_rows = [] - # License - tk.Label(root, text="License").grid(row=3, column=0, sticky="w") - entry_license = tk.Entry(root, width=40) - entry_license.insert(0, "MIT") - entry_license.grid(row=3, column=1, pady=2) + def add_intent_row(): + idx = len(intent_rows) + name_entry = tk.Entry(intents_frame, width=20) + templates_entry = tk.Entry(intents_frame, width=30) + keywords_entry = tk.Entry(intents_frame, width=30) + name_entry.grid(row=idx+1, column=0, padx=5, pady=2) + templates_entry.grid(row=idx+1, column=1, padx=5) + keywords_entry.grid(row=idx+1, column=2, padx=5) + intent_rows.append((name_entry, templates_entry, keywords_entry)) - # Keywords - tk.Label(root, text="Intent Keywords (comma-separated)").grid(row=4, column=0, sticky="w") - entry_keywords = tk.Entry(root, width=40) - entry_keywords.grid(row=4, column=1, pady=2) + tk.Button(root, text="Add Intent", command=add_intent_row).grid(row=4, column=0) + add_intent_row() # one row to start - # Actions def on_create(): name = entry_name.get().strip() description = text_desc.get("1.0", tk.END).strip() license_name = entry_license.get().strip() - keywords = [kw.strip() for kw in entry_keywords.get().split(",") if kw.strip()] + intent_data = [] + for name_e, temp_e, key_e in intent_rows: + intent_name = name_e.get().strip() + if not intent_name: + continue + templates = [t.strip() for t in temp_e.get().split(",") if t.strip()] + keywords = [k.strip() for k in key_e.get().split(",") if k.strip()] + intent_data.append({ + "intent_name": intent_name, + "templates": templates, + "keywords": keywords + }) - if not name: - messagebox.showerror("Error", "Plugin name is required.") + if not name or not intent_data: + messagebox.showerror("Error", "Plugin name and at least one intent required.") return - create_plugin_skeleton(name, description, license_name, keywords) - messagebox.showinfo("Success", f"Plugin '{name}' created successfully!") + create_plugin_skeleton(name, description, license_name, intent_data) + messagebox.showinfo("Success", f"Plugin '{name}' created!") tk.Button(root, text="Create Plugin", command=on_create).grid(row=5, column=0, pady=10) - tk.Button(root, text="Exit", command=root.destroy).grid(row=5, column=1, pady=10) + tk.Button(root, text="Exit", command=root.destroy).grid(row=5, column=1) root.mainloop() diff --git a/main.py b/main.py index 4b7d99c..59b33f9 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ -# main.py -import gui +from gui import run if __name__ == "__main__": - gui.run() + run() diff --git a/plugin_generator.py b/plugin_generator.py index 4236dc7..c0aa1b5 100644 --- a/plugin_generator.py +++ b/plugin_generator.py @@ -1,34 +1,56 @@ -# plugin_generator.py import os +from pathlib import Path +from utils import save_file -def create_plugin_skeleton(name, description, license_name, keywords): +TEMPLATE_INFO = """[plugin] +name={name} +description={description} +license={license} +type=speechhandler +version=0.0.1 +""" + +TEMPLATE_INIT = '''from naomi.plugin import SpeechHandlerPlugin + +class {class_name}(SpeechHandlerPlugin): + def intents(self): + return {intents_dict} + + def handle(self, intent, mic): + mic.say(intent.get('text', '')) +''' + +def create_plugin_skeleton(name, description, license_name, intent_data): folder = name.replace(" ", "_") os.makedirs(folder, exist_ok=True) # plugin.info - with open(os.path.join(folder, "plugin.info"), "w", encoding="utf-8") as f: - f.write(f"name={name}\n") - f.write(f"description={description}\n") - f.write(f"license={license_name}\n") - f.write(f"keywords={','.join(keywords)}\n") - - # __init__.py - with open(os.path.join(folder, "__init__.py"), "w", encoding="utf-8") as f: - f.write("# Naomi speechhandler plugin\n") - - # Handler file - handler_filename = name.replace(" ", "") + "Handler.py" - with open(os.path.join(folder, handler_filename), "w", encoding="utf-8") as f: - f.write(f'''from naomi import plugin - -class {name.replace(" ", "")}Handler(plugin.SpeechHandlerPlugin): - def intents(self): - return {{ - "RepeatIntent": {{ - "keywords": {keywords} - }} - }} - - def handle(self, text, intent): - self.say(text) -''') + info = TEMPLATE_INFO.format(name=name, description=description, license=license_name) + save_file(Path(folder) / "plugin.info", info) + + # Build intents dict + intents = {} + for intent in intent_data: + intents[intent["intent_name"]] = { + "locale": { + "en-US": { + "templates": intent["templates"], + "keywords": intent["keywords"] + } + }, + "action": "self.handle" + } + + # render __init__.py + class_name = name.replace(" ", "") + "Handler" + init_code = TEMPLATE_INIT.format( + class_name=class_name, + intents_dict=intents + ) + save_file(Path(folder) / "__init__.py", init_code) + + # README + readme = f"# {name}\n\n{description}\n" + save_file(Path(folder) / "README.md", readme) + + return folder diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..7dd60c6 --- /dev/null +++ b/utils.py @@ -0,0 +1,5 @@ +from pathlib import Path + +def save_file(path: Path, content: str): + with open(path, "w", encoding="utf-8") as f: + f.write(content) From 0322634b657c675e1da63bdc519881313a772d30 Mon Sep 17 00:00:00 2001 From: Aaron Chantrill Date: Thu, 21 Aug 2025 18:51:59 -0400 Subject: [PATCH 4/5] Fourth request to ChatGPT Here is my fourth request to ChatGPT: Okay, but the keywords are still just a simple list. The keywords should be a dictionary. This is so the keyword name can be used to pass the value in the resulting user intent. For instance, if the user says "What is the forecast for tomorrow?" the intent sent to the handler would be something like this: intent: { "matches": { [ 'WeatherKeyword': ['forecast'], 'DayKeyword': ['tomorrow'] ] }, 'text': "What is the forecast tomorrow" } That way the plugin just has to check the value of intent['matches']['DayKeyword'] to know what day(s) the user is asking about. "Also, a 'publish' tab sounds great. Please go ahead and add that functionality" The response is this version, which finally got the actual intent structure correct, but it did contain a couple of other minor errors that prevented it from running. The next commit will contain the fixes for those issues. --- gui.py | 70 ---------- main.py | 103 ++++++++++++++- plugin_editor.py | 304 ++++++++++++++++++++++++++++++++++++++++++++ plugin_generator.py | 133 +++++++++++++------ publisher.py | 178 ++++++++++++++++++++++++++ requirements.txt | 1 + storage.py | 17 +++ utils.py | 5 - 8 files changed, 697 insertions(+), 114 deletions(-) delete mode 100644 gui.py create mode 100644 plugin_editor.py create mode 100644 publisher.py create mode 100644 requirements.txt create mode 100644 storage.py delete mode 100644 utils.py diff --git a/gui.py b/gui.py deleted file mode 100644 index d299d28..0000000 --- a/gui.py +++ /dev/null @@ -1,70 +0,0 @@ -import tkinter as tk -from tkinter import messagebox, scrolledtext -from plugin_generator import create_plugin_skeleton - -def run(): - root = tk.Tk() - root.title("Naomi Plugin Creator") - - # Plugin metadata - tk.Label(root, text="Plugin Name").grid(row=0, column=0, sticky="w") - entry_name = tk.Entry(root, width=40) - entry_name.grid(row=0, column=1, pady=2) - - tk.Label(root, text="Description").grid(row=1, column=0, sticky="nw") - text_desc = scrolledtext.ScrolledText(root, width=30, height=3) - text_desc.grid(row=1, column=1, pady=2) - - tk.Label(root, text="License").grid(row=2, column=0, sticky="w") - entry_license = tk.Entry(root, width=40); entry_license.insert(0, "MIT") - entry_license.grid(row=2, column=1, pady=2) - - # Intent input - intents_frame = tk.Frame(root) - intents_frame.grid(row=3, column=0, columnspan=2, pady=10, sticky="w") - - tk.Label(intents_frame, text="Intents:").grid(row=0, column=0, sticky="w") - - intent_rows = [] - - def add_intent_row(): - idx = len(intent_rows) - name_entry = tk.Entry(intents_frame, width=20) - templates_entry = tk.Entry(intents_frame, width=30) - keywords_entry = tk.Entry(intents_frame, width=30) - name_entry.grid(row=idx+1, column=0, padx=5, pady=2) - templates_entry.grid(row=idx+1, column=1, padx=5) - keywords_entry.grid(row=idx+1, column=2, padx=5) - intent_rows.append((name_entry, templates_entry, keywords_entry)) - - tk.Button(root, text="Add Intent", command=add_intent_row).grid(row=4, column=0) - add_intent_row() # one row to start - - def on_create(): - name = entry_name.get().strip() - description = text_desc.get("1.0", tk.END).strip() - license_name = entry_license.get().strip() - intent_data = [] - for name_e, temp_e, key_e in intent_rows: - intent_name = name_e.get().strip() - if not intent_name: - continue - templates = [t.strip() for t in temp_e.get().split(",") if t.strip()] - keywords = [k.strip() for k in key_e.get().split(",") if k.strip()] - intent_data.append({ - "intent_name": intent_name, - "templates": templates, - "keywords": keywords - }) - - if not name or not intent_data: - messagebox.showerror("Error", "Plugin name and at least one intent required.") - return - - create_plugin_skeleton(name, description, license_name, intent_data) - messagebox.showinfo("Success", f"Plugin '{name}' created!") - - tk.Button(root, text="Create Plugin", command=on_create).grid(row=5, column=0, pady=10) - tk.Button(root, text="Exit", command=root.destroy).grid(row=5, column=1) - - root.mainloop() diff --git a/main.py b/main.py index 59b33f9..7768b49 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,103 @@ -from gui import run +# main.py +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from plugin_editor.py import PluginEditor +from publisher import publish_plugin_folder, read_plugin_info + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("Naomi Plugin Tool") + self.geometry("980x720") + + nb = ttk.Notebook(self) + nb.pack(fill="both", expand=True) + + # Editor tab + self.editor = PluginEditor(nb) + nb.add(self.editor, text="Plugin Editor") + + # Publish tab + self.pub_tab = ttk.Frame(nb) + nb.add(self.pub_tab, text="Publish") + self._build_publish_tab() + + def _build_publish_tab(self): + f = self.pub_tab + + row = 0 + ttk.Label(f, text="GitHub Username").grid(row=row, column=0, sticky="w", padx=10, pady=8) + self.gh_user = ttk.Entry(f, width=40) + self.gh_user.grid(row=row, column=1, sticky="w", padx=10, pady=8) + + row += 1 + ttk.Label(f, text="GitHub Token (for PR & fork)").grid(row=row, column=0, sticky="w", padx=10, pady=8) + self.gh_token = ttk.Entry(f, width=60, show="*") + self.gh_token.grid(row=row, column=1, sticky="w", padx=10, pady=8) + + row += 1 + ttk.Label(f, text="Plugin Folder").grid(row=row, column=0, sticky="w", padx=10, pady=8) + pf_row = ttk.Frame(f); pf_row.grid(row=row, column=1, sticky="w", padx=10, pady=8) + self.plugin_folder = ttk.Entry(pf_row, width=60) + self.plugin_folder.pack(side="left") + ttk.Button(pf_row, text="Browse", command=self._pick_folder).pack(side="left", padx=6) + + row += 1 + sep = ttk.Separator(f, orient="horizontal") + sep.grid(row=row, column=0, columnspan=2, sticky="ew", padx=10, pady=10) + + row += 1 + ttk.Label(f, text="Detected from plugin.info").grid(row=row, column=0, sticky="w", padx=10, pady=4) + + row += 1 + ttk.Label(f, text="Plugin Name").grid(row=row, column=0, sticky="w", padx=10, pady=4) + self.name_val = ttk.Entry(f, width=50); self.name_val.grid(row=row, column=1, sticky="w", padx=10, pady=4) + + row += 1 + ttk.Label(f, text="Repo URL (SSH)").grid(row=row, column=0, sticky="w", padx=10, pady=4) + self.repo_val = ttk.Entry(f, width=70); self.repo_val.grid(row=row, column=1, sticky="w", padx=10, pady=4) + + row += 1 + ttk.Label(f, text="License").grid(row=row, column=0, sticky="w", padx=10, pady=4) + self.license_val = ttk.Entry(f, width=30); self.license_val.grid(row=row, column=1, sticky="w", padx=10, pady=4) + + row += 1 + ttk.Button(f, text="Publish (Push + PR)", command=self._do_publish).grid(row=row, column=0, columnspan=2, pady=18) + + # stretch cols + f.grid_columnconfigure(1, weight=1) + + def _pick_folder(self): + folder = filedialog.askdirectory(title="Select Plugin Folder") + if not folder: + return + self.plugin_folder.delete(0, "end") + self.plugin_folder.insert(0, folder) + + try: + info = read_plugin_info(folder) + self.name_val.delete(0, "end"); self.name_val.insert(0, info.get("name", "")) + self.repo_val.delete(0, "end"); self.repo_val.insert(0, info.get("repo_url", "")) + self.license_val.delete(0, "end"); self.license_val.insert(0, info.get("license", "")) + except Exception as e: + messagebox.showerror("Error", f"Failed to read plugin.info: {e}") + + def _do_publish(self): + folder = self.plugin_folder.get().strip() + token = self.gh_token.get().strip() + user = self.gh_user.get().strip() + + if not folder or not token or not user: + messagebox.showerror("Missing info", "Please provide GitHub username, token, and plugin folder.") + return + + try: + pr_url = publish_plugin_folder(folder, token, user) + messagebox.showinfo("Success", f"Pull Request created:\n{pr_url}") + except Exception as e: + messagebox.showerror("Publish failed", str(e)) + if __name__ == "__main__": - run() + App().mainloop() diff --git a/plugin_editor.py b/plugin_editor.py new file mode 100644 index 0000000..270ff61 --- /dev/null +++ b/plugin_editor.py @@ -0,0 +1,304 @@ +# plugin_editor.py +import tkinter as tk +from tkinter import ttk, messagebox, simpledialog, scrolledtext +import re +from plugin_generator import create_plugin_skeleton + +PLACEHOLDER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") + +class LocaleEditor(tk.Toplevel): + """Edit one locale: keywords (dict[str, list[str]]) and templates (list[str]).""" + def __init__(self, master, initial=None, locale_code="en-US"): + super().__init__(master) + self.title(f"Locale: {locale_code}") + self.resizable(False, False) + self.result = None + self.locale_code = locale_code + self.keywords = dict((initial or {}).get("keywords", {})) + self.templates = list((initial or {}).get("templates", [])) + + # Keywords section + row = 0 + ttk.Label(self, text="Keyword Categories").grid(row=row, column=0, padx=8, pady=6, sticky="w") + kw_frame = ttk.Frame(self); kw_frame.grid(row=row, column=1, padx=8, pady=6, sticky="nsew") + self.kw_list = tk.Listbox(kw_frame, width=45, height=8); self.kw_list.pack(side="left", fill="both", expand=True) + kw_btns = ttk.Frame(kw_frame); kw_btns.pack(side="right", fill="y") + ttk.Button(kw_btns, text="Add", width=10, command=self.add_kw).pack(pady=2) + ttk.Button(kw_btns, text="Edit", width=10, command=self.edit_kw).pack(pady=2) + ttk.Button(kw_btns, text="Delete", width=10, command=self.del_kw).pack(pady=2) + + # Templates + row += 1 + ttk.Label(self, text="Templates (use {CategoryName})").grid(row=row, column=0, padx=8, pady=6, sticky="w") + tm_frame = ttk.Frame(self); tm_frame.grid(row=row, column=1, padx=8, pady=6, sticky="nsew") + self.tm_list = tk.Listbox(tm_frame, width=45, height=8); self.tm_list.pack(side="left", fill="both", expand=True) + tm_btns = ttk.Frame(tm_frame); tm_btns.pack(side="right", fill="y") + ttk.Button(tm_btns, text="Add", width=10, command=self.add_tm).pack(pady=2) + ttk.Button(tm_btns, text="Edit", width=10, command=self.edit_tm).pack(pady=2) + ttk.Button(tm_btns, text="Delete", width=10, command=self.del_tm).pack(pady=2) + + # Save/Cancel + row += 1 + btns = ttk.Frame(self); btns.grid(row=row, column=0, columnspan=2, pady=10) + ttk.Button(btns, text="Save", width=12, command=self.on_save).pack(side="left", padx=8) + ttk.Button(btns, text="Cancel", width=12, command=self.destroy).pack(side="left", padx=8) + + self.refresh() + + self.grab_set() + self.transient(master) + + def refresh(self): + self.kw_list.delete(0, tk.END) + for k in sorted(self.keywords.keys()): + self.kw_list.insert(tk.END, f"{k}: {', '.join(self.keywords[k])}") + self.tm_list.delete(0, tk.END) + for t in self.templates: + self.tm_list.insert(tk.END, t) + + def add_kw(self): + name = simpledialog.askstring("Keyword Category", "Category name, e.g. DayKeyword:", parent=self) + if not name: + return + if name in self.keywords: + from tkinter import messagebox + messagebox.showerror("Exists", "That category already exists.", parent=self) + return + phrases = simpledialog.askstring("Phrases", "Comma-separated phrases:", parent=self) or "" + vals = [p.strip() for p in phrases.split(",") if p.strip()] + self.keywords[name] = vals + self.refresh() + + def edit_kw(self): + sel = self.kw_list.curselection() + if not sel: return + key = sorted(self.keywords.keys())[sel[0]] + new_name = simpledialog.askstring("Rename", "New category name:", initialvalue=key, parent=self) + if not new_name: return + phrases = simpledialog.askstring("Phrases", "Edit comma-separated phrases:", + initialvalue=", ".join(self.keywords[key]), parent=self) or "" + vals = [p.strip() for p in phrases.split(",") if p.strip()] + if new_name != key and new_name in self.keywords: + from tkinter import messagebox + messagebox.showerror("Exists", "Category already exists.", parent=self) + return + if new_name != key: + self.keywords[new_name] = vals + del self.keywords[key] + else: + self.keywords[key] = vals + self.refresh() + + def del_kw(self): + sel = self.kw_list.curselection() + if not sel: return + key = sorted(self.keywords.keys())[sel[0]] + # warn if templates reference {key} + used = [t for t in self.templates if f"{{{key}}}" in t] + if used: + from tkinter import messagebox + if not messagebox.askyesno("Referenced", f"Templates reference {{{key}}}. Delete anyway?", parent=self): + return + del self.keywords[key] + self.refresh() + + def add_tm(self): + t = simpledialog.askstring("Template", "Template text:", parent=self) + if not t: return + self.templates.append(t.strip()); self.refresh() + + def edit_tm(self): + sel = self.tm_list.curselection() + if not sel: return + idx = sel[0]; t = self.templates[idx] + nt = simpledialog.askstring("Template", "Edit template:", initialvalue=t, parent=self) + if not nt: return + self.templates[idx] = nt.strip(); self.refresh() + + def del_tm(self): + sel = self.tm_list.curselection() + if not sel: return + del self.templates[sel[0]]; self.refresh() + + def on_save(self): + # Validate placeholders exist as keyword categories + cats = set(self.keywords.keys()) + missing = set() + for t in self.templates: + for ph in PLACEHOLDER_RE.findall(t): + if ph not in cats: + missing.add(ph) + if missing: + from tkinter import messagebox + messagebox.showerror("Missing categories", + "Placeholders missing keyword categories:\n" + ", ".join(sorted(missing)), + parent=self) + return + self.result = {"keywords": self.keywords, "templates": self.templates} + self.destroy() + + +class IntentEditor(tk.Toplevel): + """Edit a full intent with multiple locales.""" + def __init__(self, master, initial=None): + super().__init__(master) + self.title("Intent Editor") + self.resizable(False, False) + self.result = None + initial = initial or {"intent_name": "", "locales": {}} # locales: { 'en-US': {keywords,templates}, ... } + self.intent_name = tk.StringVar(value=initial.get("intent_name", "")) + self.locales = dict(initial.get("locales", {})) + + row = 0 + ttk.Label(self, text="Intent Name").grid(row=row, column=0, padx=8, pady=8, sticky="w") + ttk.Entry(self, width=32, textvariable=self.intent_name).grid(row=row, column=1, padx=8, pady=8, sticky="w") + + row += 1 + ttk.Label(self, text="Locales").grid(row=row, column=0, padx=8, sticky="nw") + lf = ttk.Frame(self); lf.grid(row=row, column=1, padx=8, pady=8, sticky="nsew") + self.loc_list = tk.Listbox(lf, width=40, height=10); self.loc_list.pack(side="left", fill="both", expand=True) + btns = ttk.Frame(lf); btns.pack(side="right", fill="y") + ttk.Button(btns, text="Add", width=10, command=self.add_locale).pack(pady=2) + ttk.Button(btns, text="Edit", width=10, command=self.edit_locale).pack(pady=2) + ttk.Button(btns, text="Delete", width=10, command=self.del_locale).pack(pady=2) + + row += 1 + pane = ttk.Frame(self); pane.grid(row=row, column=0, columnspan=2, pady=10) + ttk.Button(pane, text="Save", command=self.on_save, width=12).pack(side="left", padx=6) + ttk.Button(pane, text="Cancel", command=self.destroy, width=12).pack(side="left", padx=6) + + self.refresh_locales() + self.grab_set() + self.transient(master) + + def refresh_locales(self): + self.loc_list.delete(0, tk.END) + for code in sorted(self.locales.keys()): + cats = ", ".join(sorted(self.locales[code].get("keywords", {}).keys())) + tcount = len(self.locales[code].get("templates", [])) + self.loc_list.insert(tk.END, f"{code} cats: [{cats}] templates: {tcount}") + + def add_locale(self): + code = simpledialog.askstring("Locale Code", "e.g., en-US, fr-FR:", parent=self) + if not code: return + if code in self.locales: + messagebox.showerror("Exists", "Locale already exists.", parent=self); return + dlg = LocaleEditor(self, initial={"keywords": {}, "templates": []}, locale_code=code) + self.wait_window(dlg) + if dlg.result: + self.locales[code] = dlg.result + self.refresh_locales() + + def edit_locale(self): + sel = self.loc_list.curselection() + if not sel: return + code = sorted(self.locales.keys())[sel[0]] + dlg = LocaleEditor(self, initial=self.locales[code], locale_code=code) + self.wait_window(dlg) + if dlg.result: + self.locales[code] = dlg.result + self.refresh_locales() + + def del_locale(self): + sel = self.loc_list.curselection() + if not sel: return + code = sorted(self.locales.keys())[sel[0]] + del self.locales[code] + self.refresh_locales() + + def on_save(self): + name = self.intent_name.get().strip() + if not name: + messagebox.showerror("Missing", "Intent name is required.", parent=self); return + if not self.locales: + messagebox.showerror("Missing", "Add at least one locale.", parent=self); return + self.result = {"intent_name": name, "locales": self.locales} + self.destroy() + + +class PluginEditor(ttk.Frame): + """Main editor tab for plugin metadata and intents.""" + def __init__(self, parent): + super().__init__(parent) + self.plugin = { + "name": "", + "description": "", + "license": "MIT", + "intents": [] # list of {intent_name, locales:{code:{keywords,templates}}} + } + + row = 0 + ttk.Label(self, text="Plugin Name").grid(row=row, column=0, padx=10, pady=8, sticky="w") + self.ent_name = ttk.Entry(self, width=40); self.ent_name.grid(row=row, column=1, padx=10, pady=8, sticky="w") + + row += 1 + ttk.Label(self, text="Description").grid(row=row, column=0, padx=10, pady=2, sticky="nw") + self.txt_desc = scrolledtext.ScrolledText(self, width=56, height=6) + self.txt_desc.grid(row=row, column=1, padx=10, pady=2, sticky="w") + + row += 1 + ttk.Label(self, text="License").grid(row=row, column=0, padx=10, pady=8, sticky="w") + self.ent_license = ttk.Entry(self, width=20) + self.ent_license.insert(0, "MIT") + self.ent_license.grid(row=row, column=1, padx=10, pady=8, sticky="w") + + row += 1 + ttk.Label(self, text="Intents").grid(row=row, column=0, padx=10, pady=8, sticky="nw") + it_frame = ttk.Frame(self); it_frame.grid(row=row, column=1, padx=10, pady=8, sticky="nsew") + self.lb_intents = tk.Listbox(it_frame, width=60, height=10) + self.lb_intents.pack(side="left", fill="both", expand=True) + btns = ttk.Frame(it_frame); btns.pack(side="right", fill="y") + ttk.Button(btns, text="Add", width=12, command=self.add_intent).pack(pady=2) + ttk.Button(btns, text="Edit", width=12, command=self.edit_intent).pack(pady=2) + ttk.Button(btns, text="Delete", width=12, command=self.del_intent).pack(pady=2) + + row += 1 + ttk.Button(self, text="Generate Plugin", command=self.generate).grid(row=row, column=0, columnspan=2, pady=14) + + # stretch + self.grid_columnconfigure(1, weight=1) + + def refresh_intents(self): + self.lb_intents.delete(0, tk.END) + for it in self.plugin["intents"]: + self.lb_intents.insert(tk.END, f"{it['intent_name']} ({len(it['locales'])} locales)") + + def add_intent(self): + dlg = IntentEditor(self) + self.wait_window(dlg) + if dlg.result: + self.plugin["intents"].append(dlg.result) + self.refresh_intents() + + def edit_intent(self): + sel = self.lb_intents.curselection() + if not sel: return + obj = self.plugin["intents"][sel[0]] + dlg = IntentEditor(self, initial=obj) + self.wait_window(dlg) + if dlg.result: + self.plugin["intents"][sel[0]] = dlg.result + self.refresh_intents() + + def del_intent(self): + sel = self.lb_intents.curselection() + if not sel: return + del self.plugin["intents"][sel[0]] + self.refresh_intents() + + def generate(self): + name = self.ent_name.get().strip() + desc = self.txt_desc.get("1.0", tk.END).strip() + lic = self.ent_license.get().strip() or "MIT" + if not name: + messagebox.showerror("Missing", "Plugin name is required."); return + if not self.plugin["intents"]: + messagebox.showerror("Missing", "Add at least one intent."); return + + outdir = create_plugin_skeleton( + name=name, + description=desc, + license_name=lic, + intents=self.plugin["intents"] + ) + messagebox.showinfo("Success", f"Created: {outdir}") diff --git a/plugin_generator.py b/plugin_generator.py index c0aa1b5..f0e0156 100644 --- a/plugin_generator.py +++ b/plugin_generator.py @@ -1,56 +1,115 @@ -import os +# plugin_generator.py from pathlib import Path -from utils import save_file - -TEMPLATE_INFO = """[plugin] -name={name} -description={description} -license={license} -type=speechhandler -version=0.0.1 +import re +import pprint + +PLUGIN_INFO = """[plugin] +name = {name} +description = {description} +license = {license} +type = speechhandler +version = 0.0.1 +repo_url = {repo_url} """ -TEMPLATE_INIT = '''from naomi.plugin import SpeechHandlerPlugin +INIT_TEMPLATE = '''# Generated by Naomi Plugin Tool +from naomi.plugin import SpeechHandlerPlugin class {class_name}(SpeechHandlerPlugin): def intents(self): - return {intents_dict} + # Returned structure: + # {{ + # "IntentName": {{ + # "locale": {{ + # "en-US": {{ + # "keywords": {{"Cat": ["a","b"]}}, + # "templates": [self.gettext("...")] + # }}, + # ... + # }}, + # "action": self.handle + # }} + # }} + return {intents_src} def handle(self, intent, mic): - mic.say(intent.get('text', '')) + text = intent.get('text', '') + matches = intent.get('matches', {{}}) + if matches: + mic.say(self.gettext("I heard matches:")) + for k, v in matches.items(): + mic.say(f"{{k}}: " + ", ".join(v)) + return True + if text: + mic.say(text) + return True + return False ''' -def create_plugin_skeleton(name, description, license_name, intent_data): - folder = name.replace(" ", "_") - os.makedirs(folder, exist_ok=True) +README_TEMPLATE = """# {name} + +{description} + +This plugin was scaffolded by Naomi Plugin Tool. + +- `plugin.info` contains metadata +- `__init__.py` contains the SpeechHandler with `intents()` and `handle()` +""" - # plugin.info - info = TEMPLATE_INFO.format(name=name, description=description, license=license_name) - save_file(Path(folder) / "plugin.info", info) +def _render_templates(templates): + # -> [self.gettext("..."), ...] (escaped) + out = [] + for t in templates: + esc = t.replace("\\", "\\\\").replace('"', r'\"') + out.append(f'self.gettext("{esc}")') + return "[" + ", ".join(out) + "]" - # Build intents dict +def _build_intents_src(intents_list): + """ + intents_list: [ {intent_name, locales:{ code:{keywords, templates} }} ] + """ + # Build a python-literal dict but inject code for templates and action intents = {} - for intent in intent_data: - intents[intent["intent_name"]] = { - "locale": { - "en-US": { - "templates": intent["templates"], - "keywords": intent["keywords"] - } - }, + for intent in intents_list: + name = intent["intent_name"] + locmap = {} + for code, spec in intent["locales"].items(): + locmap[code] = { + "keywords": spec.get("keywords", {}), + "__TEMPLATES__": _render_templates(spec.get("templates", [])) + } + intents[name] = { + "locale": locmap, "action": "self.handle" } - # render __init__.py - class_name = name.replace(" ", "") + "Handler" - init_code = TEMPLATE_INIT.format( - class_name=class_name, - intents_dict=intents + src = pprint.pformat(intents, width=100, compact=False) + # action: "self.handle" -> self.handle + src = src.replace("'self.handle'", "self.handle") + # Replace "__TEMPLATES__" placeholders + src = re.sub(r"'__TEMPLATES__': '(\[.*?\])'", + lambda m: f"'templates': {m.group(1)}", + src, flags=re.S) + return src + +def create_plugin_skeleton(name, description, license_name, intents): + pkg = re.sub(r"[^A-Za-z0-9_]+", "", name.replace(" ", "_")) or "plugin" + out = Path(pkg) + out.mkdir(parents=True, exist_ok=True) + + info_text = PLUGIN_INFO.format( + name=name, + description=description, + license=license_name, + repo_url="" ) - save_file(Path(folder) / "__init__.py", init_code) + (out / "plugin.info").write_text(info_text, encoding="utf-8") + + class_name = re.sub(r"[^A-Za-z0-9]+", "", name.title()) + "Handler" + intents_src = _build_intents_src(intents) + (out / "__init__.py").write_text(INIT_TEMPLATE.format(class_name=class_name, intents_src=intents_src), + encoding="utf-8") - # README - readme = f"# {name}\n\n{description}\n" - save_file(Path(folder) / "README.md", readme) + (out / "README.md").write_text(README_TEMPLATE.format(name=name, description=description), encoding="utf-8") - return folder + return str(out.resolve()) diff --git a/publisher.py b/publisher.py new file mode 100644 index 0000000..040b41e --- /dev/null +++ b/publisher.py @@ -0,0 +1,178 @@ +# publisher.py +import os +import time +import subprocess +from github import Github +from storage import read_plugin_info, write_plugin_info + +def run(cmd, cwd=None): + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if p.returncode != 0: + raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") + return p.stdout.strip() + +def _safe_branch(s: str) -> str: + return "".join(ch.lower() if ch.isalnum() else "-" for ch in s).strip("-") or "add-plugin" + +def _ensure_remote(cwd, name, url): + try: + run(["git", "remote", "get-url", name], cwd=cwd) + run(["git", "remote", "set-url", name, url], cwd=cwd) + except RuntimeError: + run(["git", "remote", "add", name, url], cwd=cwd) + +def _ensure_fork(token, username): + """Ensure user has a fork of NaomiProject/naomi-plugins. Returns clone SSH URL.""" + g = Github(token) + upstream = g.get_repo("NaomiProject/naomi-plugins") + try: + fork = g.get_user().get_repo("naomi-plugins") + except Exception: + fork = None + if not fork: + upstream.create_fork() + # wait until fork becomes available + for _ in range(30): + try: + fork = g.get_user().get_repo("naomi-plugins") + break + except Exception: + time.sleep(2) + if not fork: + raise RuntimeError("Timed out waiting for fork creation.") + return fork.ssh_url + +def _ensure_plugin_repo(plugin_folder, token, username): + """ + Make sure the plugin folder is a git repo, remote points to user's GitHub, + push to main; update plugin.info's repo_url if we had to create a new repo, + and COMMIT that change immediately. + Returns (plugin_name, repo_ssh_url, license, latest_commit_sha) + """ + cfg, info_path = read_plugin_info(plugin_folder) + name = cfg["plugin"].get("name", "").strip() + license_text = cfg["plugin"].get("license", "MIT").strip() + repo_url = cfg["plugin"].get("repo_url", "").strip() + + if not name: + raise RuntimeError("plugin.info missing 'name'.") + + g = Github(token) + user = g.get_user() + + # Ensure .git + if not os.path.exists(os.path.join(plugin_folder, ".git")): + run(["git", "init"], cwd=plugin_folder) + + repo = None + if repo_url: + # derive repo name from URL + base = os.path.basename(repo_url) + reponame = base[:-4] if base.endswith(".git") else base + try: + repo = user.get_repo(reponame) + except Exception: + repo = None + else: + # create new repo if missing + reponame = "".join(ch for ch in name if ch.isalnum() or ch in "-_").strip() or "naomi-plugin" + try: + repo = user.get_repo(reponame) + except Exception: + repo = None + if not repo: + repo = user.create_repo(reponame, private=False, auto_init=False) + repo_url = repo.ssh_url + # write repo_url into plugin.info and COMMIT it immediately + cfg["plugin"]["repo_url"] = repo_url + write_plugin_info(cfg, info_path) + run(["git", "add", "plugin.info"], cwd=plugin_folder) + try: + run(["git", "commit", "-m", "Set repo_url in plugin.info"], cwd=plugin_folder) + except RuntimeError: + # if file already committed unchanged, ignore + pass + + # Ensure origin remote is correct + _ensure_remote(plugin_folder, "origin", repo_url) + + # Commit all current work and push main + run(["git", "add", "-A"], cwd=plugin_folder) + try: + run(["git", "commit", "-m", "Update plugin source"], cwd=plugin_folder) + except RuntimeError: + pass + run(["git", "branch", "-M", "main"], cwd=plugin_folder) + run(["git", "push", "-u", "origin", "main"], cwd=plugin_folder) + + # Latest commit SHA + branch = repo.get_branch("main") + sha = branch.commit.sha + return name, repo_url, license_text, sha + +def _ensure_local_plugins_index(username, token): + """Clone or update user's fork of naomi-plugins into ./naomi-plugins and sync upstream.""" + target_dir = "naomi-plugins" + if not os.path.exists(target_dir): + fork_ssh = _ensure_fork(token, username) + run(["git", "clone", fork_ssh, target_dir]) + + cwd = os.path.abspath(target_dir) + # Make sure upstream is set + _ensure_remote(cwd, "upstream", "git@github.com:NaomiProject/naomi-plugins.git") + + run(["git", "fetch", "origin"], cwd=cwd) + run(["git", "fetch", "upstream"], cwd=cwd) + # default branch name can be master or main; check both + default_branch = "master" + try: + run(["git", "rev-parse", "--verify", "upstream/master"], cwd=cwd) + except RuntimeError: + default_branch = "main" + run(["git", "rev-parse", "--verify", "upstream/main"], cwd=cwd) + + run(["git", "checkout", "-B", default_branch, f"origin/{default_branch}"], cwd=cwd) + run(["git", "merge", f"upstream/{default_branch}"], cwd=cwd) + + return cwd, default_branch + +def publish_plugin_folder(plugin_folder, github_token, github_username): + """ + 1) Ensure plugin repo is up-to-date on user's GitHub (SSH), updating plugin.info repo_url & committing immediately if needed. + 2) Ensure local clone of user's fork of naomi-plugins is present and in sync with upstream. + 3) Create a branch, append line to plugins.csv with: name, repo_url, commit_sha, license. + 4) Push branch to user's fork and open PR (PyGithub) against NaomiProject/naomi-plugins. + Returns PR URL. + """ + name, repo_url, license_text, sha = _ensure_plugin_repo(plugin_folder, github_token, github_username) + idx_dir, base_branch = _ensure_local_plugins_index(github_username, github_token) + + branch_name = _safe_branch(f"add-{name}") + run(["git", "checkout", "-B", branch_name], cwd=idx_dir) + + csv_path = os.path.join(idx_dir, "plugins.csv") + line = f"{name},{repo_url},{sha},{license_text}\n" + with open(csv_path, "a", encoding="utf-8") as f: + f.write(line) + + run(["git", "add", "plugins.csv"], cwd=idx_dir) + run(["git", "commit", "-m", f"Add {name} plugin"], cwd=idx_dir) + run(["git", "push", "origin", branch_name], cwd=idx_dir) + + g = Github(github_token) + upstream = g.get_repo("NaomiProject/naomi-plugins") + pr = upstream.create_pull( + title=f"Add {name} plugin", + body=f"Registering plugin **{name}**\n\n- Repo: {repo_url}\n- Commit: `{sha}`\n- License: {license_text}", + head=f"{github_username}:{branch_name}", + base=base_branch + ) + return pr.html_url + +def read_plugin_info(folder): + cfg, _ = read_plugin_info(folder) + return { + "name": cfg["plugin"].get("name", ""), + "repo_url": cfg["plugin"].get("repo_url", ""), + "license": cfg["plugin"].get("license", ""), + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4b29ec6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyGithub>=2.3.0 diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..db03bc7 --- /dev/null +++ b/storage.py @@ -0,0 +1,17 @@ +# storage.py +import os +import configparser + +def read_plugin_info(folder): + path = os.path.join(folder, "plugin.info") + if not os.path.exists(path): + raise FileNotFoundError("plugin.info not found") + cfg = configparser.ConfigParser() + cfg.read(path) + if "plugin" not in cfg: + raise ValueError("Missing [plugin] section") + return cfg, path + +def write_plugin_info(cfg, path): + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) diff --git a/utils.py b/utils.py deleted file mode 100644 index 7dd60c6..0000000 --- a/utils.py +++ /dev/null @@ -1,5 +0,0 @@ -from pathlib import Path - -def save_file(path: Path, content: str): - with open(path, "w", encoding="utf-8") as f: - f.write(content) From dbab6874b8f20df6325fd98275a50c1dcfa84f83 Mon Sep 17 00:00:00 2001 From: Aaron Chantrill Date: Mon, 25 Aug 2025 18:58:42 -0400 Subject: [PATCH 5/5] Fixes to Fourth Request A couple of these issues showed up when I first ran the code, but most of them showed up when I tried testing the plugin that the code generated. The actual process of pushing the generated plugin, forking the naomi-plugins repository, adding a line to plugins.csv, pushing that to the forked repository and then creating a pull request all appears to have gone fine except for the fact that the master branch of my forked repository had diverged. I've since learned the importance of always committing my changes to a temporary branch so I can keep my master/main branch synchronized with upstream. main.py - Added a shebang and set the execute bit in permissions so it can be run directly rather than as a python argument. Added logging and fixed the name of an imported function that I had to change. plugin_editor.py - It bothered me that the background colors of the form controls where inconsistent for no good reason. I decided I wanted to use white for controls that the user can type directly into, and grey for controls that are read-only or have to be modified using the side-buttons. plugin_generator.py - This was where I found most of the issues, and mostly with the plugin that the system generated rather than with the program itself running. First, it imported the SpeechHandlerPlugin class directly, which caused an issue where the plugin system was only expecting one class to be directly defined. I had to change it to import plugin rather than the SpeechHandlerPlugin class directly. Second, I really didn't like the way it formatted the intents it was defining. It was using pprint.pformat for formatting the intent structure, but this was doing odd stuff like inserting single quotes into the wrong places, causing the resulting plugin to generate syntax errors. I much prefer the formatting of json.dumps, so I switched to using that. I was also able to simplify the generation code, as the program was doing unnecessary processing. Third, a simple handle script like this should always return True. It would only return False if the handler determined that it was unable to handle the request. This is generally used with expect() and confirm(). The keywords were being defined with self.gettext() around them, which is not necessary or desireable here because we are defining different locales, and there may be more ways a user might choose to say something in one language than another. GetText is more for translating interface labels to different languages. This is probably because of one of the examples that I showed to ChatGPT. Finally, the request text is gotten using the 'input' property of the intent, not the 'text' property. publisher.py - the "Plugin" section and "Name" and "License" values must be capitalized in order to be used by Naomi. Also, the repository url belongs in the "URL" value, not "repo_url". Finally, ChatGPT both imported a read_plugin_info function from storage and then defined a read_plugin_info function that called it, resulting in an infinite loop/stack overflow. This was an interesting experiment. Most of the mistakes that ChatGPT made were similar to things I would expect a junior programmer to have trouble with also, but I don't know that I can explain why things should have been written differently to ChatGPT or if that would be worthwhile. It was motivating to see the application come together so quickly, and I enjoy debugging so I think it was helpful. --- .gitignore | 3 + README.md | 149 ++++++++++++++++++++++++++++++++++++++++++++ main.py | 10 ++- plugin_editor.py | 76 ++++++++++++++++------ plugin_generator.py | 58 ++++++++--------- publisher.py | 18 ++++-- storage.py | 2 +- 7 files changed, 256 insertions(+), 60 deletions(-) mode change 100644 => 100755 main.py diff --git a/.gitignore b/.gitignore index b7faf40..3ec69c7 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Ghostwriter stuff +*.backup diff --git a/README.md b/README.md index ea84db8..faecb13 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,151 @@ # Speechhandler_Creator A vibe-coding project with ChatGPT - an editor for creating speechhandler plugins + +The **Speechhandler_Creator** is a graphical tool for building and publishing speechhandler plugins for the [Naomi voice assistant](https://github.com/NaomiProject/Naomi). + +It simplifies the entire process of: +- Defining **intents** (keywords + templates) for your plugin +- Generating a Python `SpeechHandler` plugin template +- Publishing your plugin to GitHub +- Submitting a pull request to the [Naomi Plugin Exchange](https://github.com/NaomiProject/naomi-plugins) + +This tool helps developers jump straight into writing plugin logic without worrying about boilerplate setup. + +--- + +## Installation + +Clone this repository: + +```bash +git clone https://github.com/NaomiProject/naomi-plugin-creator.git +cd naomi-plugin-creator +``` + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Requirements: +- Python 3.9+ +- [tkinter](https://docs.python.org/3/library/tkinter.html) (usually comes with Python) +- [PyGithub](https://pygithub.readthedocs.io/en/latest/) +- A GitHub account with a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) (classic, repo scope) + +--- + +## Running the Tool + +Start the application: + +```bash +python main.py +``` + +The Plugin Creator window will open with tabs for: +- **General Info** – name, description, license, repo URL +- **Intents** – define keywords and templates +- **Locales** – manage multiple language variants +- **Publish** – push your plugin to GitHub and the Naomi registry + +--- + +## Creating a Plugin (Example: Weather Report) + +Let’s create a plugin called `WeatherPlugin`. + +### Step 1: General Info +- **Name**: `WeatherPlugin` +- **Description**: "Provides local weather forecasts." +- **License**: `MIT` +- **Repo URL**: leave blank to let the tool create a GitHub repo for you. + +### Step 2: Add Intents +Think of how you might expect a user to interact with your plugin. For the weather plugin, the user might use phrases such as: +* What is the forecast? +* Will it be windy this afternoon? +* When is it going to rain in Cincinnati? + +Obviously, you don't want to have to enter every possible way that a user might interact with your plugin, but you can break down the phrases into templates and keywords. An additional benefit of this is that Naomi will return the user's request to your plugin with the keywords split out already so your plugin does not have to parse the user's input directly. Keywords found in user input can be picked up from the "intent['matches']" key in the "intent" object received by the "handle" method. + +An "intent" for Naomi is basically asking for a particular program to be run. Each intent can be sent to a different handler function, or you can have one handler function for your intent that performs different functions depending on the value of "intent['intent']". + +To add an intent, click the "Add" button next to the "intents" area. Give your intent a name, then a brief description to help users know what your plugin does. + +#### Step 2A: Add Keywords +Add keywords so Naomi can extract values from user input. + +Click the "Add" button next to "Keyword Categories". +Give your keyword a descriptive name and add a comma separated list of possible values. + +Example keywords output: +```json +{ + "ForecastKeyword": ["forecast", "outlook"], + "WeatherConditionKeyword": ["rain", "snow", "be windy", "be sunny"], + "DayKeyword": ["today", "tomorrow", "Monday", "this"], + "TimeOfDayKeyword": ["morning", "afternoon", "evening", "night"], + "LocationKeyword": ["Cincinnati", "New York", "Chicago"] +} +``` + +#### Step 2B: Add Templates +Templates show how keywords fit into natural phrases. + +Example templates: +- `"What is the {ForecastKeyword}?"` +- `"Will it {WeatherConditionKeyword} {DayKeyword} {TimeOfDayKeyword}?"` +- `"When is it going to {WeatherConditionKeyword} in {LocationKeyword}?"` + +Use curly brackets to denote that the intent parser should expect a keyword from a keyword list in that location. + +### Step 3: Generate Plugin +Click **Generate Plugin** + +This creates: +``` +WeatherPlugin/ + ├── __init__.py # plugin template + ├── plugin.info + ├── README.md +``` + +The starter plugin simply repeats back the detected intent for debugging. To test your plugin, copy the whole directory to one of the Naomi speechhandler plugins directories - either ~/Naomi/plugins/speechhander or ~/.config/naomi/plugins/speechhandler - and re-start Naomi. Watch for any error messages saying that your plugin has been skipped. Try triggering your plugin by saying some of the phrases you designed for it. + +### Step 4: Implement Your Logic +Open `__init__.py` in your favorite editor and replace the handler logic with real API calls. + +### Step 5: Publish +When ready: +1. Re-open the Plugin Creator +2. Go to the **Publish** tab +3. Select your plugin project +4. Enter your [GitHub token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +5. Click **Publish** + +The tool will: +- Commit any changes +- Push to your GitHub repo +- Create a pull request to add your plugin to the Naomi registry + +--- +## Troubleshooting + +One particularly vexing issue was trying to publish an update when the "master" branch of my forked copy of the naomi-plugins repository had diverged from the "master" branch of the main repository. This was causing the final pull request to fail. After trying a few things, I ended up simply deleting my forked copy and re-forking it fresh, which was okay because there really wasn't anything in there that I was worried about losing. In the years since I first forked that repository, I have learned the value of always creating a new branch for my changes so I can keep the master/main branch synchronized with upstream. + +If you run into other issues with this program, please open an issue on the Github repository. + +--- + +## Contributing + +Pull requests are welcome! +See [Naomi Project](https://github.com/NaomiProject/Naomi) for full developer documentation. + +--- + +## License + +MIT License (same as the Naomi project). diff --git a/main.py b/main.py old mode 100644 new mode 100755 index 7768b49..5c4b6a4 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ +#!/usr/bin/env python3 # main.py import tkinter as tk from tkinter import ttk, filedialog, messagebox -from plugin_editor.py import PluginEditor -from publisher import publish_plugin_folder, read_plugin_info +from plugin_editor import PluginEditor +from publisher import publish_plugin_folder, _read_plugin_info +import logging class App(tk.Tk): @@ -10,6 +12,7 @@ def __init__(self): super().__init__() self.title("Naomi Plugin Tool") self.geometry("980x720") + self._logger = logging.getLogger(__name__) nb = ttk.Notebook(self) nb.pack(fill="both", expand=True) @@ -76,12 +79,13 @@ def _pick_folder(self): self.plugin_folder.insert(0, folder) try: - info = read_plugin_info(folder) + info = _read_plugin_info(folder) self.name_val.delete(0, "end"); self.name_val.insert(0, info.get("name", "")) self.repo_val.delete(0, "end"); self.repo_val.insert(0, info.get("repo_url", "")) self.license_val.delete(0, "end"); self.license_val.insert(0, info.get("license", "")) except Exception as e: messagebox.showerror("Error", f"Failed to read plugin.info: {e}") + self._logger.error(f"Failed to read plugin.info: {e}", exc_info=True) def _do_publish(self): folder = self.plugin_folder.get().strip() diff --git a/plugin_editor.py b/plugin_editor.py index 270ff61..b0fd127 100644 --- a/plugin_editor.py +++ b/plugin_editor.py @@ -4,8 +4,28 @@ import re from plugin_generator import create_plugin_skeleton + PLACEHOLDER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") + +class CustomAskString(simpledialog.Dialog): + def __init__(self, parent, title, prompt, default=""): + self.prompt = prompt + self.default = default + self.result = None + super().__init__(parent, title) + + def body(self, master): + tk.Label(master, text=self.prompt).pack(pady=5) + self.entry = tk.Entry(master, bg="white") + self.entry.pack(pady=5) + return self.entry # Set initial focus + + def apply(self): + """Process the data when OK is clicked.""" + self.result = self.entry.get() + + class LocaleEditor(tk.Toplevel): """Edit one locale: keywords (dict[str, list[str]]) and templates (list[str]).""" def __init__(self, master, initial=None, locale_code="en-US"): @@ -57,14 +77,16 @@ def refresh(self): self.tm_list.insert(tk.END, t) def add_kw(self): - name = simpledialog.askstring("Keyword Category", "Category name, e.g. DayKeyword:", parent=self) + dialog = CustomAskString(self, "Keyword Category", "Category name, e.g. DayKeyword:") + name = dialog.result if not name: return if name in self.keywords: from tkinter import messagebox messagebox.showerror("Exists", "That category already exists.", parent=self) return - phrases = simpledialog.askstring("Phrases", "Comma-separated phrases:", parent=self) or "" + dialog = CustomAskString(self, "Phrases", "Comma-separated phrases:") + phrases = dialog.result vals = [p.strip() for p in phrases.split(",") if p.strip()] self.keywords[name] = vals self.refresh() @@ -73,10 +95,12 @@ def edit_kw(self): sel = self.kw_list.curselection() if not sel: return key = sorted(self.keywords.keys())[sel[0]] - new_name = simpledialog.askstring("Rename", "New category name:", initialvalue=key, parent=self) - if not new_name: return - phrases = simpledialog.askstring("Phrases", "Edit comma-separated phrases:", - initialvalue=", ".join(self.keywords[key]), parent=self) or "" + dialog = CustomAskString(self, "Rename", "New category name:", default=key) + new_name = dialog.result + if not new_name: + return + dialog = CustomAskString(self, "Phrases", "Edit comma-separated phrases:", default=", ".join(self.keywords[key])) + phrases = dialog.result vals = [p.strip() for p in phrases.split(",") if p.strip()] if new_name != key and new_name in self.keywords: from tkinter import messagebox @@ -91,7 +115,8 @@ def edit_kw(self): def del_kw(self): sel = self.kw_list.curselection() - if not sel: return + if not sel: + return key = sorted(self.keywords.keys())[sel[0]] # warn if templates reference {key} used = [t for t in self.templates if f"{{{key}}}" in t] @@ -103,22 +128,29 @@ def del_kw(self): self.refresh() def add_tm(self): - t = simpledialog.askstring("Template", "Template text:", parent=self) - if not t: return + dialog = CustomAskString(self, "Template", "Template text:") + t = dialog.result + if not t: + return self.templates.append(t.strip()); self.refresh() def edit_tm(self): sel = self.tm_list.curselection() if not sel: return idx = sel[0]; t = self.templates[idx] - nt = simpledialog.askstring("Template", "Edit template:", initialvalue=t, parent=self) - if not nt: return - self.templates[idx] = nt.strip(); self.refresh() + dialog = CustomAskString(self, "Template", "Edit template:", initialvalue=t) + nt = dialog.result + if not nt: + return + self.templates[idx] = nt.strip() + self.refresh() def del_tm(self): sel = self.tm_list.curselection() - if not sel: return - del self.templates[sel[0]]; self.refresh() + if not sel: + return + del self.templates[sel[0]] + self.refresh() def on_save(self): # Validate placeholders exist as keyword categories @@ -130,9 +162,11 @@ def on_save(self): missing.add(ph) if missing: from tkinter import messagebox - messagebox.showerror("Missing categories", - "Placeholders missing keyword categories:\n" + ", ".join(sorted(missing)), - parent=self) + messagebox.showerror( + "Missing categories", + "Placeholders missing keyword categories:\n" + ", ".join(sorted(missing)), + parent=self + ) return self.result = {"keywords": self.keywords, "templates": self.templates} self.destroy() @@ -179,8 +213,10 @@ def refresh_locales(self): self.loc_list.insert(tk.END, f"{code} cats: [{cats}] templates: {tcount}") def add_locale(self): - code = simpledialog.askstring("Locale Code", "e.g., en-US, fr-FR:", parent=self) - if not code: return + dialog = CustomAskString(self, "Locale Code", "e.g., en-US, fr-FR:") + code = dialog.result + if not code: + return if code in self.locales: messagebox.showerror("Exists", "Locale already exists.", parent=self); return dlg = LocaleEditor(self, initial={"keywords": {}, "templates": []}, locale_code=code) @@ -233,7 +269,7 @@ def __init__(self, parent): row += 1 ttk.Label(self, text="Description").grid(row=row, column=0, padx=10, pady=2, sticky="nw") - self.txt_desc = scrolledtext.ScrolledText(self, width=56, height=6) + self.txt_desc = scrolledtext.ScrolledText(self, bg="white", width=56, height=6) self.txt_desc.grid(row=row, column=1, padx=10, pady=2, sticky="w") row += 1 diff --git a/plugin_generator.py b/plugin_generator.py index f0e0156..cc8054d 100644 --- a/plugin_generator.py +++ b/plugin_generator.py @@ -1,21 +1,24 @@ # plugin_generator.py +import json from pathlib import Path import re -import pprint - -PLUGIN_INFO = """[plugin] -name = {name} -description = {description} -license = {license} -type = speechhandler -version = 0.0.1 -repo_url = {repo_url} + + +PLUGIN_INFO = """[Plugin] +Name = {name} +Description = {description} +License = {license} +Type = speechhandler +Version = 0.0.1 +URL = {repo_url} """ + INIT_TEMPLATE = '''# Generated by Naomi Plugin Tool -from naomi.plugin import SpeechHandlerPlugin +from naomi import plugin -class {class_name}(SpeechHandlerPlugin): + +class {class_name}(plugin.SpeechHandlerPlugin): def intents(self): # Returned structure: # {{ @@ -23,7 +26,7 @@ def intents(self): # "locale": {{ # "en-US": {{ # "keywords": {{"Cat": ["a","b"]}}, - # "templates": [self.gettext("...")] + # "templates": ["..."] # }}, # ... # }}, @@ -33,19 +36,18 @@ def intents(self): return {intents_src} def handle(self, intent, mic): - text = intent.get('text', '') + text = intent.get('input', '') matches = intent.get('matches', {{}}) + if text: + mic.say(text) if matches: mic.say(self.gettext("I heard matches:")) for k, v in matches.items(): mic.say(f"{{k}}: " + ", ".join(v)) - return True - if text: - mic.say(text) - return True - return False + return True ''' + README_TEMPLATE = """# {name} {description} @@ -56,13 +58,6 @@ def handle(self, intent, mic): - `__init__.py` contains the SpeechHandler with `intents()` and `handle()` """ -def _render_templates(templates): - # -> [self.gettext("..."), ...] (escaped) - out = [] - for t in templates: - esc = t.replace("\\", "\\\\").replace('"', r'\"') - out.append(f'self.gettext("{esc}")') - return "[" + ", ".join(out) + "]" def _build_intents_src(intents_list): """ @@ -76,22 +71,23 @@ def _build_intents_src(intents_list): for code, spec in intent["locales"].items(): locmap[code] = { "keywords": spec.get("keywords", {}), - "__TEMPLATES__": _render_templates(spec.get("templates", [])) + "templates": spec.get("templates", []) } intents[name] = { "locale": locmap, "action": "self.handle" } - src = pprint.pformat(intents, width=100, compact=False) + src = json.dumps(intents, indent=4) # action: "self.handle" -> self.handle src = src.replace("'self.handle'", "self.handle") - # Replace "__TEMPLATES__" placeholders - src = re.sub(r"'__TEMPLATES__': '(\[.*?\])'", - lambda m: f"'templates': {m.group(1)}", - src, flags=re.S) + src = src.replace('"self.handle"', "self.handle") + # Insert two additional tabs (8 spaces) at the beginning of every line + src = src.replace("\n ", "\n ") + src = src.replace("\n}", "\n }") return src + def create_plugin_skeleton(name, description, license_name, intents): pkg = re.sub(r"[^A-Za-z0-9_]+", "", name.replace(" ", "_")) or "plugin" out = Path(pkg) diff --git a/publisher.py b/publisher.py index 040b41e..391b9d1 100644 --- a/publisher.py +++ b/publisher.py @@ -5,15 +5,18 @@ from github import Github from storage import read_plugin_info, write_plugin_info + def run(cmd, cwd=None): p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) if p.returncode != 0: raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") return p.stdout.strip() + def _safe_branch(s: str) -> str: return "".join(ch.lower() if ch.isalnum() else "-" for ch in s).strip("-") or "add-plugin" + def _ensure_remote(cwd, name, url): try: run(["git", "remote", "get-url", name], cwd=cwd) @@ -21,6 +24,7 @@ def _ensure_remote(cwd, name, url): except RuntimeError: run(["git", "remote", "add", name, url], cwd=cwd) + def _ensure_fork(token, username): """Ensure user has a fork of NaomiProject/naomi-plugins. Returns clone SSH URL.""" g = Github(token) @@ -42,6 +46,7 @@ def _ensure_fork(token, username): raise RuntimeError("Timed out waiting for fork creation.") return fork.ssh_url + def _ensure_plugin_repo(plugin_folder, token, username): """ Make sure the plugin folder is a git repo, remote points to user's GitHub, @@ -50,12 +55,12 @@ def _ensure_plugin_repo(plugin_folder, token, username): Returns (plugin_name, repo_ssh_url, license, latest_commit_sha) """ cfg, info_path = read_plugin_info(plugin_folder) - name = cfg["plugin"].get("name", "").strip() - license_text = cfg["plugin"].get("license", "MIT").strip() - repo_url = cfg["plugin"].get("repo_url", "").strip() + name = cfg["plugin"].get("Name", "").strip() + license_text = cfg["plugin"].get("License", "MIT").strip() + repo_url = cfg["plugin"].get("URL", "").strip() if not name: - raise RuntimeError("plugin.info missing 'name'.") + raise RuntimeError("plugin.info missing 'Name'.") g = Github(token) user = g.get_user() @@ -110,6 +115,7 @@ def _ensure_plugin_repo(plugin_folder, token, username): sha = branch.commit.sha return name, repo_url, license_text, sha + def _ensure_local_plugins_index(username, token): """Clone or update user's fork of naomi-plugins into ./naomi-plugins and sync upstream.""" target_dir = "naomi-plugins" @@ -136,6 +142,7 @@ def _ensure_local_plugins_index(username, token): return cwd, default_branch + def publish_plugin_folder(plugin_folder, github_token, github_username): """ 1) Ensure plugin repo is up-to-date on user's GitHub (SSH), updating plugin.info repo_url & committing immediately if needed. @@ -169,7 +176,8 @@ def publish_plugin_folder(plugin_folder, github_token, github_username): ) return pr.html_url -def read_plugin_info(folder): + +def _read_plugin_info(folder): cfg, _ = read_plugin_info(folder) return { "name": cfg["plugin"].get("name", ""), diff --git a/storage.py b/storage.py index db03bc7..0213f55 100644 --- a/storage.py +++ b/storage.py @@ -8,7 +8,7 @@ def read_plugin_info(folder): raise FileNotFoundError("plugin.info not found") cfg = configparser.ConfigParser() cfg.read(path) - if "plugin" not in cfg: + if "Plugin" not in cfg: raise ValueError("Missing [plugin] section") return cfg, path