Write a plugin
Ship a ready-made set of dirsql tables — an embeddings index, a log-line extractor, a metrics view — so a teammate gets them by installing a package, with zero config edits. A plugin is an ordinary pip package that carries a dirsql.toml config fragment and declares itself with a dirsql entry point; when it is installed in the same environment as the uvx/pip launcher, the launcher discovers it and loads its fragment automatically. Nothing in dirsql is plugin-aware — a plugin is just config plus a naming convention.
Package layout
A plugin is a normal Python package. Two things make it a plugin:
- A
dirsql.tomlfragment shipped inside a top-level module. - A
[project.entry-points.dirsql]declaration pointing the launcher at that module.
dirsql-embeddings/
├── pyproject.toml
└── src/
└── dirsql_embeddings/
├── __init__.py
├── dirsql.toml # the config fragment
├── embed.py # on-file hook
└── search.py # pre-query hookThe entry point maps a source label (the name) to the module that ships dirsql.toml (the value):
# pyproject.toml
[project]
name = "dirsql-embeddings"
version = "0.1.0"
dependencies = ["model2vec", "sqlite-vec"]
[project.entry-points.dirsql]
embeddings = "dirsql_embeddings"The entry-point name (embeddings) is the source label the launcher uses to identify the plugin in diagnostics; the value (dirsql_embeddings) is the importable module the launcher resolves to find dirsql.toml beside it. Installing the package is all it takes — there is no enable step and no filename convention beyond dirsql.toml.
The fragment is an ordinary config
A plugin's dirsql.toml is a plain config file — structurally identical to a user config. It may declare [[table]] (with on-file), [[dirsql.extension]], ignore, pre-query/post-query, and hook-timeout.
There are no plugin-specific keys and no plugin-specific restrictions. The config schema is content-only: the index root and --persist are runner-owned flags (--root, --persist [PATH]), decided by whoever runs dirsql, never by a config file — so a plugin has nothing to say about them. Whatever you can put in your own .dirsql.toml, a plugin can put in its fragment, and vice-versa.
Hook commands
Both hook command styles from the hook contract work in a plugin fragment:
- Console scripts — a
bin-style entry point your package installs onPATH(embed-file {path}). Recommended for published plugins: the command is bound to your package's interpreter and dependencies, and it is language-neutral (the fragment names a command, not a Python file). - Relative scripts — a path resolved against the fragment's own directory (
uv run python embed.py {path}). Convenient while developing the plugin in-tree.
Two facts from the execution contract matter most for a published plugin:
- A hook runs in its declaring config's directory. For a plugin that is the installed fragment's directory — inside site-packages. That is a read-only, shared location: run from it, never write to it. Use the absolute
{path}placeholder to read the matched file, and{root}to reach the user's project directory. Write any cache to{root}or a real cache dir, never next to the fragment. {path}is absolute and{root}is the index root, so a command is self-sufficient from any working directory — it works whether the plugin lives in the project or in site-packages.
Worked example: an embeddings plugin
Here is the whole plugin — vector search over a directory of notes, buildable in about fifty lines. It composes the same three pieces as Search documents by meaning: the sqlite-vec extension for the distance math, an on-file command to embed each file, and a pre-query command to embed the question.
The fragment, src/dirsql_embeddings/dirsql.toml:
[dirsql]
pre-query = "uv run --with model2vec python search.py {args}"
hook-timeout = 300 # headroom for the first-run model download
[[dirsql.extension]]
path = "sqlite_vec"
entrypoint = "sqlite3_vec_init"
[[table]]
ddl = "CREATE TABLE notes (path TEXT, text TEXT, embedding TEXT)"
glob = "notes/*.md"
on-file = "uv run --with model2vec python embed.py {path}"embed.py turns one file into one row carrying its text and its embedding:
"""Embed one file's text; print a dirsql row array on stdout."""
import json
import sys
from model2vec import StaticModel
path = sys.argv[1]
text = open(path, encoding="utf-8").read()
model = StaticModel.from_pretrained("minishlab/potion-base-8M")
vector = model.encode([text])[0]
print(json.dumps([{"text": text, "embedding": json.dumps([round(float(x), 6) for x in vector])}]))search.py turns a {"q": "..."} request body into nearest-neighbor SQL:
"""Turn a {"q": "..."} request body into a nearest-neighbor SQL query."""
import json
import sys
from model2vec import StaticModel
body = json.loads(sys.argv[1])
model = StaticModel.from_pretrained("minishlab/potion-base-8M")
vector = model.encode([body["q"]])[0]
needle = json.dumps([round(float(x), 6) for x in vector])
print(
"SELECT path, ROUND(vec_distance_cosine(embedding, '%s'), 3) AS distance "
"FROM notes ORDER BY distance LIMIT 3" % needle
)The hook owns SQL safety
Whatever SQL pre-query prints is executed as-is. Here the interpolated value is a numeric vector the script itself produced; never splice raw request text into SQL. See pre-query.
The relative embed.py / search.py above resolve against the fragment directory, which is convenient during development. For a published plugin, promote them to console scripts ([project.scripts] → embed-file, embed-search) so the commands carry their own interpreter and dependencies and no longer depend on uv run --with.
Once the package is installed alongside the launcher, its notes table is queryable with no config edits:
uvx --with dirsql-embeddings dirsql query '{"q": "how do I cook pasta?"}'The launcher discovers the installed plugin, composes its fragment, and the pre-query hook turns the question into vector-distance SQL.
SDK-style convention: expose the config
The launcher's auto-discovery is launcher-only: an SDK consumer never gets a plugin's tables automatically. The convention that bridges this — zero dirsql code, purely a plugin courtesy — is to also expose the fragment's path programmatically, so an application can pass it to the constructor's config parameter by hand:
# src/dirsql_embeddings/__init__.py
from importlib.resources import files
def config_path() -> str:
"""Absolute path to this plugin's dirsql.toml, for SDK consumers."""
return str(files(__package__) / "dirsql.toml")from dirsql import DirSQL
from dirsql_embeddings import config_path
db = DirSQL("./project", config=config_path())This is not a dirsql feature — it is a naming convention a well-behaved plugin follows so its config is reachable both ways: auto-discovered by the launcher, and hand-passed to an SDK.
Boundaries
Discovery is deliberately narrow. Know exactly who does what:
- The
cargo-installed binary does no discovery. It loads configs only from-c/--config. A standalone binary user passes a plugin's fragment explicitly, like any other config. - The
uvx/piplauncher injects-c <fragment>per installed plugin. Each discovered plugin's fragment is appended after your own-cconfigs, so your config still takes ordering precedence (composing configs). When you pass no-cof your own, the launcher also keeps the baked-in defaultfilestable (an internal--include-default), so plugins add tables rather than replacing the default. Discovery is pip/uvx only for now — thenpxlauncher does not yet discover — and is switched off per invocation with--no-pluginorDIRSQL_NO_PLUGIN=1. - The SDK never auto-discovers. Pass a plugin's config explicitly (the convention above).
- Name collisions are a hard error. Because fragments compose like any multiple configs, two plugins (or a plugin and your config) defining a table of the same name — or the same query hook — fail loudly, naming the conflict. It is never a silent last-writer-wins.
Config flags are subcommand-local
For dirsql query, pass -c and friends after the subcommand (dirsql query "<sql>" -c <cfg>); before it they are a hard error. Plugin discovery is unaffected — the launcher injects its -c flags in the right position — but it matters when you also pass a config of your own. See the CLI reference.