modlink_studio.plugin.cli

  1from __future__ import annotations
  2
  3import argparse
  4import json
  5import os
  6import subprocess
  7import sys
  8import tempfile
  9from dataclasses import dataclass
 10from importlib.metadata import PackageNotFoundError, entry_points, version
 11from pathlib import Path
 12from typing import Any
 13from urllib.error import HTTPError, URLError
 14from urllib.request import Request, urlopen
 15
 16from packaging.specifiers import SpecifierSet
 17from packaging.version import InvalidVersion, Version
 18from platformdirs import user_cache_path
 19
 20PLUGIN_ENTRY_POINT_GROUP = "modlink.drivers"
 21DEFAULT_PLUGIN_INDEX_URL = (
 22    "https://modlink-studio.github.io/ModLink-Studio-Plugins/plugins/index.json"
 23)
 24PLUGIN_INDEX_URL_ENV = "MODLINK_PLUGIN_INDEX_URL"
 25USER_AGENT = "modlink-plugin"
 26
 27
 28@dataclass(frozen=True)
 29class PluginRelease:
 30    version: str
 31    host_version_spec: str
 32    wheel_url: str
 33
 34
 35@dataclass(frozen=True)
 36class IndexedPlugin:
 37    plugin_id: str
 38    distribution: str
 39    display_name: str
 40    summary: str
 41    releases: tuple[PluginRelease, ...]
 42
 43
 44@dataclass(frozen=True)
 45class InstalledPlugin:
 46    plugin_id: str
 47    distribution: str
 48    version: str | None
 49
 50
 51def _host_version() -> str:
 52    try:
 53        return version("modlink-studio")
 54    except PackageNotFoundError:
 55        return "0.0.0"
 56
 57
 58def _manifest_url() -> str:
 59    return os.environ.get(PLUGIN_INDEX_URL_ENV, DEFAULT_PLUGIN_INDEX_URL)
 60
 61
 62def _cache_path() -> Path:
 63    return user_cache_path("modlink-studio") / "plugin-index.json"
 64
 65
 66def _fetch_json(url: str) -> dict[str, Any]:
 67    request = Request(
 68        url,
 69        headers={
 70            "Accept": "application/json",
 71            "User-Agent": USER_AGENT,
 72        },
 73    )
 74    with urlopen(request, timeout=10) as response:
 75        payload = json.load(response)
 76    if not isinstance(payload, dict):
 77        raise RuntimeError("Plugin index must be a JSON object.")
 78    return payload
 79
 80
 81def _load_manifest() -> list[IndexedPlugin]:
 82    cache_path = _cache_path()
 83    payload: dict[str, Any] | None = None
 84    fetch_error: str | None = None
 85    try:
 86        payload = _fetch_json(_manifest_url())
 87    except (HTTPError, URLError, TimeoutError, RuntimeError, OSError) as exc:
 88        fetch_error = str(exc)
 89
 90    if payload is not None:
 91        cache_path.parent.mkdir(parents=True, exist_ok=True)
 92        cache_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
 93        return _parse_manifest(payload)
 94
 95    if cache_path.exists():
 96        cached_payload = json.loads(cache_path.read_text(encoding="utf-8"))
 97        if not isinstance(cached_payload, dict):
 98            raise RuntimeError("Cached plugin index is invalid.")
 99        return _parse_manifest(cached_payload)
100
101    raise RuntimeError(
102        "Failed to load plugin index and no cached copy is available."
103        if fetch_error is None
104        else f"Failed to load plugin index ({fetch_error}) and no cached copy is available."
105    )
106
107
108def _parse_manifest(payload: dict[str, Any]) -> list[IndexedPlugin]:
109    schema_version = payload.get("schema_version")
110    if schema_version != 1:
111        raise RuntimeError(f"Unsupported plugin index schema version: {schema_version!r}")
112
113    plugins = payload.get("plugins")
114    if not isinstance(plugins, list):
115        raise RuntimeError("Plugin index is missing a valid 'plugins' array.")
116
117    parsed: list[IndexedPlugin] = []
118    for item in plugins:
119        if not isinstance(item, dict):
120            raise RuntimeError("Each plugin entry must be an object.")
121        releases_payload = item.get("releases")
122        if not isinstance(releases_payload, list):
123            raise RuntimeError("Each plugin entry must include a 'releases' array.")
124
125        releases: list[PluginRelease] = []
126        for release in releases_payload:
127            if not isinstance(release, dict):
128                raise RuntimeError("Each plugin release entry must be an object.")
129            version_text = release.get("version")
130            host_version_spec = release.get("host_version_spec")
131            wheel_url = release.get("wheel_url")
132            if (
133                not isinstance(version_text, str)
134                or not isinstance(host_version_spec, str)
135                or not isinstance(wheel_url, str)
136            ):
137                raise RuntimeError(
138                    "Plugin release entries must include version, host_version_spec, and wheel_url."
139                )
140            releases.append(
141                PluginRelease(
142                    version=version_text,
143                    host_version_spec=host_version_spec,
144                    wheel_url=wheel_url,
145                )
146            )
147
148        plugin_id = item.get("plugin_id")
149        distribution_name = item.get("distribution")
150        display_name = item.get("display_name")
151        summary = item.get("summary")
152        if (
153            not isinstance(plugin_id, str)
154            or not isinstance(distribution_name, str)
155            or not isinstance(display_name, str)
156            or not isinstance(summary, str)
157        ):
158            raise RuntimeError(
159                "Plugin entries must include plugin_id, distribution, display_name, and summary."
160            )
161        parsed.append(
162            IndexedPlugin(
163                plugin_id=plugin_id,
164                distribution=distribution_name,
165                display_name=display_name,
166                summary=summary,
167                releases=tuple(releases),
168            )
169        )
170
171    return sorted(parsed, key=lambda item: item.plugin_id)
172
173
174def _installed_plugins() -> list[InstalledPlugin]:
175    installed: list[InstalledPlugin] = []
176    for entry_point in sorted(
177        entry_points(group=PLUGIN_ENTRY_POINT_GROUP), key=lambda item: item.name
178    ):
179        dist = getattr(entry_point, "dist", None)
180        distribution_name = getattr(dist, "name", None) or getattr(dist, "metadata", {}).get(
181            "Name", "unknown"
182        )
183        plugin_version = getattr(dist, "version", None)
184        installed.append(
185            InstalledPlugin(
186                plugin_id=entry_point.name,
187                distribution=distribution_name,
188                version=plugin_version,
189            )
190        )
191    return installed
192
193
194def _indexed_plugins_by_id(indexed_plugins: list[IndexedPlugin]) -> dict[str, IndexedPlugin]:
195    return {plugin.plugin_id: plugin for plugin in indexed_plugins}
196
197
198def _installed_plugins_by_id(
199    installed_plugins: list[InstalledPlugin],
200) -> dict[str, InstalledPlugin]:
201    return {plugin.plugin_id: plugin for plugin in installed_plugins}
202
203
204def _distribution_aliases(installed_plugins: list[InstalledPlugin]) -> dict[str, InstalledPlugin]:
205    aliases: dict[str, InstalledPlugin] = {}
206    for plugin in installed_plugins:
207        aliases[plugin.distribution] = plugin
208        aliases[plugin.distribution.replace("-", "_")] = plugin
209    return aliases
210
211
212def _select_release(plugin: IndexedPlugin, host_version: str) -> PluginRelease:
213    try:
214        current = Version(host_version)
215    except InvalidVersion as exc:
216        raise RuntimeError(f"Invalid modlink-studio version: {host_version!r}") from exc
217
218    compatible: list[PluginRelease] = []
219    for release in plugin.releases:
220        if current in SpecifierSet(release.host_version_spec):
221            compatible.append(release)
222    if not compatible:
223        raise RuntimeError(f"No compatible release found for {plugin.plugin_id}.")
224    return max(compatible, key=lambda item: Version(item.version))
225
226
227def _download_wheel(url: str, target_dir: Path) -> Path:
228    wheel_name = url.rsplit("/", 1)[-1]
229    target_path = target_dir / wheel_name
230    request = Request(url, headers={"User-Agent": USER_AGENT})
231    with urlopen(request, timeout=30) as response:
232        target_path.write_bytes(response.read())
233    return target_path
234
235
236def _ensure_pip_available() -> None:
237    probe = subprocess.run(
238        [sys.executable, "-m", "pip", "--version"],
239        stdout=subprocess.DEVNULL,
240        stderr=subprocess.PIPE,
241        text=True,
242        check=False,
243    )
244    if probe.returncode == 0:
245        return
246
247    stderr = (probe.stderr or "").strip()
248    if "No module named pip" not in stderr:
249        raise RuntimeError(
250            "Failed to invoke pip in the current Python environment."
251            if not stderr
252            else f"Failed to invoke pip in the current Python environment: {stderr}"
253        )
254
255    subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], check=True)
256
257
258def _run_pip(*args: str) -> None:
259    _ensure_pip_available()
260    subprocess.run([sys.executable, "-m", "pip", *args], check=True)
261
262
263def _format_available(
264    plugin: IndexedPlugin, installed: InstalledPlugin | None, host_version: str
265) -> str:
266    try:
267        release = _select_release(plugin, host_version)
268        availability = f"available={release.version}"
269    except RuntimeError:
270        availability = "available=none"
271    installed_label = installed.version if installed and installed.version else "not-installed"
272    return f"{plugin.plugin_id:<18} installed={installed_label:<14} {availability:<18} {plugin.display_name}"
273
274
275def _format_installed(
276    plugin: InstalledPlugin, indexed_plugin: IndexedPlugin | None, host_version: str
277) -> str:
278    if indexed_plugin is None:
279        compatibility = "unknown"
280        source = "third-party"
281    else:
282        try:
283            _select_release(indexed_plugin, host_version)
284            compatibility = "compatible"
285        except RuntimeError:
286            compatibility = "unsupported"
287        source = "official"
288    version_label = plugin.version if plugin.version is not None else "unknown"
289    return (
290        f"{plugin.plugin_id:<18} version={version_label:<12} "
291        f"source={source:<11} compatible={compatibility:<10} dist={plugin.distribution}"
292    )
293
294
295def _cmd_list(show_installed: bool) -> int:
296    if show_installed:
297        installed_plugins = _installed_plugins()
298        if not installed_plugins:
299            print("- none")
300            return 0
301        for plugin in installed_plugins:
302            print(f"{plugin.plugin_id:<18} {plugin.distribution} {plugin.version or 'unknown'}")
303        return 0
304
305    indexed_plugins = _load_manifest()
306    for plugin in indexed_plugins:
307        print(f"{plugin.plugin_id:<18} {plugin.display_name} - {plugin.summary}")
308    return 0
309
310
311def _cmd_status() -> int:
312    host_version = _host_version()
313    indexed_plugins = _load_manifest()
314    installed_plugins = _installed_plugins()
315    indexed_by_id = _indexed_plugins_by_id(indexed_plugins)
316    installed_by_id = _installed_plugins_by_id(installed_plugins)
317
318    print(f"modlink-studio {host_version}")
319    print()
320    print("Installed")
321    if not installed_plugins:
322        print("- none")
323    else:
324        for plugin in installed_plugins:
325            print(
326                f"- {_format_installed(plugin, indexed_by_id.get(plugin.plugin_id), host_version)}"
327            )
328
329    print()
330    print("Available")
331    for plugin in indexed_plugins:
332        print(f"- {_format_available(plugin, installed_by_id.get(plugin.plugin_id), host_version)}")
333    return 0
334
335
336def _cmd_install(plugin_id: str) -> int:
337    host_version = _host_version()
338    indexed_plugins = _load_manifest()
339    indexed_by_id = _indexed_plugins_by_id(indexed_plugins)
340    plugin = indexed_by_id.get(plugin_id)
341    if plugin is None:
342        raise RuntimeError(f"Plugin '{plugin_id}' is not present in the current plugin index.")
343
344    release = _select_release(plugin, host_version)
345    with tempfile.TemporaryDirectory(prefix="modlink-plugin-") as temp_dir:
346        wheel_path = _download_wheel(release.wheel_url, Path(temp_dir))
347        _run_pip("install", str(wheel_path))
348    print(f"Installed {plugin.display_name} {release.version}.")
349    return 0
350
351
352def _cmd_uninstall(plugin_id: str) -> int:
353    installed_plugins = _installed_plugins()
354    installed_by_id = _installed_plugins_by_id(installed_plugins)
355    aliases = _distribution_aliases(installed_plugins)
356    plugin = installed_by_id.get(plugin_id) or aliases.get(plugin_id)
357    if plugin is None:
358        print(f"Plugin '{plugin_id}' is not installed.")
359        return 0
360
361    _run_pip("uninstall", "-y", plugin.distribution)
362    version_label = plugin.version or "unknown"
363    print(f"Uninstalled {plugin.distribution} {version_label}.")
364    return 0
365
366
367def build_parser() -> argparse.ArgumentParser:
368    parser = argparse.ArgumentParser(
369        prog="modlink-plugin",
370        description="Manage ModLink Studio plugins.",
371    )
372    subparsers = parser.add_subparsers(dest="command", required=True)
373
374    list_parser = subparsers.add_parser("list", help="List available plugins or installed plugins.")
375    list_parser.add_argument(
376        "--installed",
377        action="store_true",
378        help="List plugins currently installed in this environment.",
379    )
380
381    subparsers.add_parser("status", help="Show plugin environment overview.")
382
383    install_parser = subparsers.add_parser(
384        "install", help="Install one plugin from the plugin index."
385    )
386    install_parser.add_argument("plugin_id")
387
388    uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall one installed plugin.")
389    uninstall_parser.add_argument("plugin_id")
390    return parser
391
392
393def main(argv: list[str] | None = None) -> int:
394    parser = build_parser()
395    args = parser.parse_args(argv)
396
397    if args.command == "list":
398        return _cmd_list(args.installed)
399    if args.command == "status":
400        return _cmd_status()
401    if args.command == "install":
402        return _cmd_install(args.plugin_id)
403    if args.command == "uninstall":
404        return _cmd_uninstall(args.plugin_id)
405    parser.error(f"Unknown command: {args.command}")
406    return 2
407
408
409if __name__ == "__main__":
410    raise SystemExit(main())
PLUGIN_ENTRY_POINT_GROUP = 'modlink.drivers'
DEFAULT_PLUGIN_INDEX_URL = 'https://modlink-studio.github.io/ModLink-Studio-Plugins/plugins/index.json'
PLUGIN_INDEX_URL_ENV = 'MODLINK_PLUGIN_INDEX_URL'
USER_AGENT = 'modlink-plugin'
@dataclass(frozen=True)
class PluginRelease:
29@dataclass(frozen=True)
30class PluginRelease:
31    version: str
32    host_version_spec: str
33    wheel_url: str
PluginRelease(version: str, host_version_spec: str, wheel_url: str)
version: str
host_version_spec: str
wheel_url: str
@dataclass(frozen=True)
class IndexedPlugin:
36@dataclass(frozen=True)
37class IndexedPlugin:
38    plugin_id: str
39    distribution: str
40    display_name: str
41    summary: str
42    releases: tuple[PluginRelease, ...]
IndexedPlugin( plugin_id: str, distribution: str, display_name: str, summary: str, releases: tuple[PluginRelease, ...])
plugin_id: str
distribution: str
display_name: str
summary: str
releases: tuple[PluginRelease, ...]
@dataclass(frozen=True)
class InstalledPlugin:
45@dataclass(frozen=True)
46class InstalledPlugin:
47    plugin_id: str
48    distribution: str
49    version: str | None
InstalledPlugin(plugin_id: str, distribution: str, version: str | None)
plugin_id: str
distribution: str
version: str | None
def build_parser() -> argparse.ArgumentParser:
368def build_parser() -> argparse.ArgumentParser:
369    parser = argparse.ArgumentParser(
370        prog="modlink-plugin",
371        description="Manage ModLink Studio plugins.",
372    )
373    subparsers = parser.add_subparsers(dest="command", required=True)
374
375    list_parser = subparsers.add_parser("list", help="List available plugins or installed plugins.")
376    list_parser.add_argument(
377        "--installed",
378        action="store_true",
379        help="List plugins currently installed in this environment.",
380    )
381
382    subparsers.add_parser("status", help="Show plugin environment overview.")
383
384    install_parser = subparsers.add_parser(
385        "install", help="Install one plugin from the plugin index."
386    )
387    install_parser.add_argument("plugin_id")
388
389    uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall one installed plugin.")
390    uninstall_parser.add_argument("plugin_id")
391    return parser
def main(argv: list[str] | None = None) -> int:
394def main(argv: list[str] | None = None) -> int:
395    parser = build_parser()
396    args = parser.parse_args(argv)
397
398    if args.command == "list":
399        return _cmd_list(args.installed)
400    if args.command == "status":
401        return _cmd_status()
402    if args.command == "install":
403        return _cmd_install(args.plugin_id)
404    if args.command == "uninstall":
405        return _cmd_uninstall(args.plugin_id)
406    parser.error(f"Unknown command: {args.command}")
407    return 2