Plugin loader (mirrors Rust #29): - plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\<name>\ for commands.yaml fragments and merges them into VA_CMD_LIST at startup. Same schema as the project root commands.yaml. Per-pack `disabled` flag file skips a pack without deletion. Built-in ids win conflicts so a malicious pack can't silently override open_browser. 9 unit tests (parity with the Rust tests). - main.py: plugin merge runs after the root yaml load; logs the number of extra commands picked up. Flet GUI (#27 — first parity with the Rust Tauri GUI): - New gui/ package: __main__.py launches via `python -m gui` (or gui.bat). app.py routes between 7 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) via NavigationRail; dark theme matching the Tauri look. - gui/services.py centralises every store-module call the pages share — single place to swap implementations later. - One file per page in gui/pages/, all expose `build(page)` and use the same card layout idiom. Pages mirror the Tauri ones feature- for-feature: home (status + counters + launcher), commands (filterable list of all VA_CMD_LIST entries), macros (list + delete + recording banner), scheduler (list + remove), memory (add/forget/search), plugins (toggle/open folder), settings (profile + LLM hot-swap). - requirements.txt: flet>=0.85 (optional; assistant runs without). - 3 smoke tests verify imports + build callables; skip cleanly when Flet isn't installed. README: documents both the GUI and the plugin layout in user-facing terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke).
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
r"""User plugin loader — mirror of the Rust plugin system.
|
|
|
|
Plugins live in `<APP_CONFIG_DIR>/plugins/<name>/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):
|
|
|
|
<APP_CONFIG_DIR>/plugins/
|
|
<pack_name>/
|
|
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 `<APP_CONFIG_DIR>/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)
|