Plugins4 of 7
Installing a plugin, and shipping one
On this page
Two paths. This page covers both from the installing person's side and from the author's side, and ends with what survives an upgrade.
Path 1: a folder in ~/.kotoba/plugins/
Installing one
mkdir -p ~/.kotoba/plugins
cp -r ./some-plugin ~/.kotoba/plugins/some-plugin
# restart her
That is the whole procedure. There is no install command, no manifest, no registry. Read the code first — see the security position.
The directory can also be a single file:
cp ./dice.py ~/.kotoba/plugins/dice.py
A loose dice.py becomes a plugin named dice. A directory weather/ containing forecast.py and
alerts.py becomes a plugin named weather with two tools.
Where the directory is comes from core/plugins.plugins_dir():
Path(os.getenv("KOTOBA_PLUGINS_PATH", str(home_dir() / "plugins"))).expanduser()
- Default:
~/.kotoba/plugins/. KOTOBA_HOMEmoves the whole home, so the plugins directory moves with it.KOTOBA_PLUGINS_PATHpoints the loader somewhere else entirely — the one to use while developing, so you never have to touch the real home.
Shipping one
Publish the directory. A README saying what it does, what it needs on PATH, and which
environment variables it reads is the entire distribution.
Constraints to design around:
- One tool per
.pyfile. A file'sSCHEMAis one tool; a second tool needs a second file. - No package semantics. The loader executes each file with
importlib.util.spec_from_file_locationand never puts it insys.modules, so__package__is empty. Relative imports do not work, and neither doesimport my_helperfor a sibling file. If you need more than one file, either put the directory onsys.pathyourself at the top of your module, or use Path 2. - You install your own dependencies. Nothing reads a requirements file.
The multi-file workaround, verified working:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _helpers import fetch # _helpers.py sits beside this file
Name the helper with a leading underscore. Files starting with _ are skipped by the loader, so it
will not be scanned for a SCHEMA of its own.
Path 2: a distribution with a kotoba.tools entry point
Installing one
pip install kotoba-plugin-dice
# restart her
Install it into the same environment Kotoba runs in. The loader reads entry points out of the running interpreter's installed distributions; a package in a different virtualenv is invisible.
Uninstalling is pip uninstall kotoba-plugin-dice and a restart.
Shipping one
kotoba-plugin-dice/
pyproject.toml
src/kotoba_dice/
__init__.py
roll.py <- the tool module: SCHEMA, execute, the rest
pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "kotoba-plugin-dice"
version = "0.1.0"
description = "A dice-rolling tool for Kotoba."
requires-python = ">=3.11"
dependencies = []
[project.entry-points."kotoba.tools"]
dice = "kotoba_dice.roll"
[tool.hatch.build.targets.wheel]
packages = ["src/kotoba_dice"]
Three things about that entry-point line:
- The group must be exactly
kotoba.tools. Nothing else is scanned. - The key is the plugin name.
dicehere means the toolset isplugin:dice, the Settings row readsdice, and the switch key isplugin:dice. It has nothing to do with the distribution name or the module path. - The value is a module path, with no
:attrsuffix. The loader callsep.load()and readsSCHEMAoff whatever comes back, so it has to be the module itself.
The tool module inside the package is exactly the same file as a folder plugin's — same SCHEMA,
same async def execute, same optional attributes. The only difference is that here you can use
ordinary imports, ordinary sub-packages, and declared dependencies.
One entry point exposes one tool. The loader registers a single SCHEMA per entry point. Two
tools means two entry points:
[project.entry-points."kotoba.tools"]
dice = "kotoba_dice.roll"
cards = "kotoba_dice.draw"
which gives two independent plugins — plugin:dice and plugin:cards — with two Settings rows and
two switches, from one pip install.
Which path to choose
Use a folder when the plugin is yours, is one file, and has no dependencies. Use a distribution the
moment you hand it to somebody else: pip gives you dependency resolution, versioning, an uninstall
command, and a name people can search for.
Turning a plugin on and off
Both paths land in the same switch, keyed plugin:<name>.
In the web app. Settings, then the Plugins section. Each row is
<name> · N tools (folder|entrypoint) with a toggle. The toggle posts to
POST /api/settings/toolset with {"name": "plugin:<name>", "enabled": true|false}.
The same plugin also appears as a row in the Toolsets section while it is on, named
plugin:<name>. It is the same switch. Turning it off there removes its tools from the registry, so
the Toolsets row disappears — and the Plugins row, which does not, is then the only way back on.
That is what the Plugins section is for.
In the terminal. /settings plugins lists them. The listing is read-only; the toggle is in the
web panel.
By hand. ~/.kotoba/settings.yaml:
disabled_toolsets:
- plugin:hackernews
Read directly by the loader before it opens anything (app_settings.saved_disabled_toolsets), so a
plugin named there is never executed. Takes effect on the next start.
What the switch actually does
Not a filter. For a plugin family — and only for a plugin family — the switch moves third-party code
in and out of the process (registry.set_toolset_enabled):
- Off deregisters every tool the plugin registered, so the name no longer resolves for a model that still remembers it. The module object stays imported: nothing can un-run code that has already run.
- On loads the plugin now, running its module body, rather than waiting for the next restart.
- Off in
settings.yamlat startup means the file is never opened at all. Its module body does not run. It still gets a row in Settings, with the switch off, because that row is the only way to turn it back on.
A built-in family behaves differently: switching web off filters it out of the schema list but
deregisters nothing, because those modules are already imported and are ours.
Adding a plugin while she is running
You cannot. Discovery happens once, at import of kotoba.tools. A plugin directory you create while
the process is up has no row in Settings, so there is nothing to toggle. Restart.
The one live path is re-enabling something that was already seen at startup.
Upgrades
Survives pip install -U kotoba-companion? | |
|---|---|
~/.kotoba/plugins/ | Yes. It is in your home, not in the package. |
| An entry-point distribution | Yes, as long as the environment is the same one. A rebuilt virtualenv loses it, like every other installed package. |
~/.kotoba/settings.yaml, including disabled_toolsets | Yes. Same home. |
Both the plugins directory and the settings file resolve through paths.home_dir() —
KOTOBA_HOME, or ~/.kotoba — which is outside the installed package on every install shape. An
upgrade replaces the code and touches nothing of yours.
What upgrades can break is the contract, not the file. Your plugin reads SCHEMA conventions,
ToolContext fields, and helpers like validate_within_dir and note_tool_refusal out of the
package. None of that is a stability-guaranteed public API. Pin the version you tested against in
your distribution's dependencies if that matters to you, and keep
a test that fails loudly when it stops being true.
