r"""User plugin loader — mirror of the Rust plugin system. Plugins live in `/plugins//commands.yaml` and follow the exact same YAML schema as the bundled `commands.yaml`. The user can drop new voice command packs in there without touching the project tree. Layout (per pack): /plugins/ / commands.yaml (required — top-level dict, same schema) disabled (optional empty file — pack is skipped) ... (any helper files referenced by handlers) Discovery is tolerant: a malformed yaml logs a warning and the pack is skipped; it never breaks the rest of the loader. `APP_CONFIG_DIR` resolves to `%APPDATA%\com.priler.jarvis\plugins\` on Windows, matching the Rust fork — so a single plugins folder can serve both installs. The id-collision policy is "first wins, plugin loses": a plugin cannot override a built-in command id. This protects against drive-by replacement of e.g. `open_browser` by a malicious pack. """ import os import yaml from typing import Dict, List, Tuple PLUGINS_DIR_NAME = "plugins" DISABLED_FLAG = "disabled" def app_config_dir() -> str: """Returns the per-user config dir matching the Rust fork's APP_CONFIG_DIR. On Windows this is `%APPDATA%\\com.priler.jarvis\\`. We don't depend on platform-dirs here — std env vars are enough. """ appdata = os.environ.get("APPDATA") if appdata: return os.path.join(appdata, "com.priler.jarvis") # fallback for non-Windows or unusual environments return os.path.join(os.path.expanduser("~"), ".config", "com.priler.jarvis") def plugins_dir() -> str: """Returns `/plugins/`, creating it lazily.""" d = os.path.join(app_config_dir(), PLUGINS_DIR_NAME) try: os.makedirs(d, exist_ok=True) except OSError: pass return d def discover() -> Dict[str, dict]: """Walks `plugins_dir()` and returns a merged dict of command_id -> entry. Caller is expected to merge this into the main VA_CMD_LIST. """ return discover_in(plugins_dir()) def discover_in(directory: str) -> Dict[str, dict]: """Like `discover` but takes an explicit directory — used by tests.""" merged: Dict[str, dict] = {} if not os.path.isdir(directory): return merged for name in sorted(os.listdir(directory)): pack_path = os.path.join(directory, name) if not os.path.isdir(pack_path): continue if os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)): continue yaml_path = os.path.join(pack_path, "commands.yaml") if not os.path.isfile(yaml_path): continue try: with open(yaml_path, "rt", encoding="utf8") as f: data = yaml.safe_load(f) or {} except (OSError, yaml.YAMLError) as e: print(f"[plugins] failed to load {yaml_path}: {e}") continue if not isinstance(data, dict): print(f"[plugins] {yaml_path}: top-level must be a dict; skipping") continue for cmd_id, entry in data.items(): if not isinstance(cmd_id, str) or not isinstance(entry, dict): continue if cmd_id in merged: print(f"[plugins] duplicate id '{cmd_id}' across packs; first wins") continue merged[cmd_id] = entry return merged def list_packs() -> List[dict]: """Light listing for the (eventual) Python GUI: name + enabled + count.""" return list_packs_in(plugins_dir()) def list_packs_in(directory: str) -> List[dict]: out: List[dict] = [] if not os.path.isdir(directory): return out for name in sorted(os.listdir(directory)): pack_path = os.path.join(directory, name) if not os.path.isdir(pack_path): continue yaml_path = os.path.join(pack_path, "commands.yaml") if not os.path.isfile(yaml_path): continue enabled = not os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)) count = 0 error = None try: with open(yaml_path, "rt", encoding="utf8") as f: data = yaml.safe_load(f) or {} if isinstance(data, dict): count = sum(1 for v in data.values() if isinstance(v, dict)) else: error = "top-level must be a dict" except (OSError, yaml.YAMLError) as e: error = str(e) out.append( { "name": name, "path": pack_path, "enabled": enabled, "command_count": count, "error": error, } ) return out def set_enabled(name: str, enabled: bool) -> Tuple[bool, str]: """Toggle the `disabled` flag for a pack. Returns (ok, message).""" pack = os.path.join(plugins_dir(), name) if not os.path.isdir(pack): return False, f"plugin '{name}' not found" flag = os.path.join(pack, DISABLED_FLAG) try: if enabled and os.path.isfile(flag): os.remove(flag) elif not enabled and not os.path.isfile(flag): with open(flag, "w", encoding="utf8") as f: f.write("") return True, "ok" except OSError as e: return False, str(e)