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 new file mode 100755 index 0000000..5c4b6a4 --- /dev/null +++ b/main.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# main.py +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from plugin_editor import PluginEditor +from publisher import publish_plugin_folder, _read_plugin_info +import logging + + +class App(tk.Tk): + 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) + + # 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}") + self._logger.error(f"Failed to read plugin.info: {e}", exc_info=True) + + 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__": + App().mainloop() diff --git a/plugin_editor.py b/plugin_editor.py new file mode 100644 index 0000000..b0fd127 --- /dev/null +++ b/plugin_editor.py @@ -0,0 +1,340 @@ +# 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 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"): + 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): + 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 + 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() + + def edit_kw(self): + sel = self.kw_list.curselection() + if not sel: return + key = sorted(self.keywords.keys())[sel[0]] + 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 + 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): + 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] + 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() + + 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): + 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) + 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, bg="white", 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 new file mode 100644 index 0000000..cc8054d --- /dev/null +++ b/plugin_generator.py @@ -0,0 +1,111 @@ +# plugin_generator.py +import json +from pathlib import Path +import re + + +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 import plugin + + +class {class_name}(plugin.SpeechHandlerPlugin): + def intents(self): + # Returned structure: + # {{ + # "IntentName": {{ + # "locale": {{ + # "en-US": {{ + # "keywords": {{"Cat": ["a","b"]}}, + # "templates": ["..."] + # }}, + # ... + # }}, + # "action": self.handle + # }} + # }} + return {intents_src} + + def handle(self, intent, mic): + 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 +''' + + +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()` +""" + + +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 intents_list: + name = intent["intent_name"] + locmap = {} + for code, spec in intent["locales"].items(): + locmap[code] = { + "keywords": spec.get("keywords", {}), + "templates": spec.get("templates", []) + } + intents[name] = { + "locale": locmap, + "action": "self.handle" + } + + src = json.dumps(intents, indent=4) + # action: "self.handle" -> self.handle + src = src.replace("'self.handle'", "self.handle") + 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) + out.mkdir(parents=True, exist_ok=True) + + info_text = PLUGIN_INFO.format( + name=name, + description=description, + license=license_name, + repo_url="" + ) + (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") + + (out / "README.md").write_text(README_TEMPLATE.format(name=name, description=description), encoding="utf-8") + + return str(out.resolve()) diff --git a/publisher.py b/publisher.py new file mode 100644 index 0000000..391b9d1 --- /dev/null +++ b/publisher.py @@ -0,0 +1,186 @@ +# 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("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..0213f55 --- /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)