J.A.R.V.I.S-py/plugins_store.py
Bossiara13 59a1258dcc feat: Wave 3 — plugin loader + Flet GUI (parity with Rust fork)
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).
2026-05-16 13:27:04 +03:00

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)