Current situation
The plugin system (minisky/plugin/plugin.py) is inherited from BlueSky and works by scanning a directory:
Plugin.find_plugins() resolves a plugin_path setting (default plugins, currently example_plugins in settings.toml), inserts its parent into sys.path, and globs **/*.py.
- Each file is parsed with
ast (without importing) to find an init_plugin() function, walking the AST backwards from the return statement to reconstruct the config dict and stack commands (Plugin._parse_init_plugin).
load_enabled() then imports and initializes the plugins listed in enabled_plugins.
Downsides of this approach:
sys.path manipulation — the plugin directory's parent is prepended to sys.path, which can shadow installed packages and makes module names dependent on directory layout.
- Fragile AST metadata extraction —
_parse_init_plugin only understands literal dicts assigned or returned in specific shapes; any indirection (config built in a helper, non-literal values) silently loses metadata. It's ~100 lines of code that exists only to avoid importing the module.
- No packaging story — a plugin can't be
pip/uv-installed as its own distribution with its own dependencies; it has to live in a directory MiniSky is pointed at. The tangram plugin already wants this (it has its own optional dependencies and workspace).
- Path resolution guesswork — relative
plugin_path is tried against the package root and then the CWD, so behavior depends on where you launch from.
Proposal
Replace directory scanning with the standard entry points mechanism, discovered via importlib.metadata.entry_points() (stdlib, no new dependency).
A plugin becomes a normal installable package that declares itself in its pyproject.toml:
[project.entry-points."minisky.plugins"]
tangram = "minisky_tangram:init_plugin"
Discovery then becomes roughly:
from importlib.metadata import entry_points
for ep in entry_points(group="minisky.plugins"):
...
discover() lists entry points in the minisky.plugins group — name and distribution metadata are available without importing anything, so the AST parser can be deleted entirely.
Plugin.load() calls ep.load()() and registers hooks/stack commands exactly as _load() does today; the init_plugin() contract (config dict + optional stack functions) can stay unchanged.
enabled_plugins in settings.toml keeps working, now naming entry points instead of scanned files.
- In-repo example plugins can register their entry points in MiniSky's own
pyproject.toml (or become a small workspace package), so uv sync makes them discoverable with no plugin_path needed.
Benefits
- Plugins are real packages: own dependencies, versioning, installable from PyPI/git (
uv add minisky-tangram), testable in isolation.
- Deletes
find_plugins + _parse_init_plugin and the sys.path insertion — a large net code reduction, in line with the project's minimalism goal.
- One less setting to explain (
plugin_path goes away).
Open questions
- Do we keep any directory-based fallback for quick single-file hacking, or is "make it a package" acceptable friction? (uv makes a minimal package cheap:
uv init --lib.)
- Should
PLUGINS LIST show all installed entry points or only enabled ones?
- What to do with the docstring/stack-command preview currently extracted by AST — entry point metadata gives us the distribution's summary, which may be enough.
Current situation
The plugin system (
minisky/plugin/plugin.py) is inherited from BlueSky and works by scanning a directory:Plugin.find_plugins()resolves aplugin_pathsetting (defaultplugins, currentlyexample_pluginsinsettings.toml), inserts its parent intosys.path, and globs**/*.py.ast(without importing) to find aninit_plugin()function, walking the AST backwards from the return statement to reconstruct the config dict and stack commands (Plugin._parse_init_plugin).load_enabled()then imports and initializes the plugins listed inenabled_plugins.Downsides of this approach:
sys.pathmanipulation — the plugin directory's parent is prepended tosys.path, which can shadow installed packages and makes module names dependent on directory layout._parse_init_pluginonly understands literal dicts assigned or returned in specific shapes; any indirection (config built in a helper, non-literal values) silently loses metadata. It's ~100 lines of code that exists only to avoid importing the module.pip/uv-installed as its own distribution with its own dependencies; it has to live in a directory MiniSky is pointed at. The tangram plugin already wants this (it has its own optional dependencies and workspace).plugin_pathis tried against the package root and then the CWD, so behavior depends on where you launch from.Proposal
Replace directory scanning with the standard entry points mechanism, discovered via
importlib.metadata.entry_points()(stdlib, no new dependency).A plugin becomes a normal installable package that declares itself in its
pyproject.toml:Discovery then becomes roughly:
discover()lists entry points in theminisky.pluginsgroup — name and distribution metadata are available without importing anything, so the AST parser can be deleted entirely.Plugin.load()callsep.load()()and registers hooks/stack commands exactly as_load()does today; theinit_plugin()contract (config dict + optional stack functions) can stay unchanged.enabled_pluginsinsettings.tomlkeeps working, now naming entry points instead of scanned files.pyproject.toml(or become a small workspace package), souv syncmakes them discoverable with noplugin_pathneeded.Benefits
uv add minisky-tangram), testable in isolation.find_plugins+_parse_init_pluginand thesys.pathinsertion — a large net code reduction, in line with the project's minimalism goal.plugin_pathgoes away).Open questions
uv init --lib.)PLUGINS LISTshow all installed entry points or only enabled ones?