155 lines
5.3 KiB
Python
155 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)
|