docs/tool-plugin-development.md
::: tip The mise-tool-plugin-template provides a ready-to-use starting point with LuaCATS type definitions, stylua formatting, and hk linting pre-configured. :::
Tool plugins use a hook-based architecture to manage individual tools. They are compatible with the standard vfox ecosystem and are perfect for tools that need complex installation logic, environment configuration, or legacy file parsing.
Tool plugins use traditional hook functions to manage a single tool. They provide:
.nvmrc, .tool-version, etc.)Tool plugins are implemented in Lua (version 5.1 at the moment). They use a hook-based architecture with specific functions for different lifecycle events:
graph TD
A[User Request] --> B[mise CLI]
B --> C[Tool Plugin]
C --> D[Available Hook
List Versions]
C --> E[PreInstall Hook
Download]
C --> F[PostInstall Hook
Setup]
C --> G[EnvKeys Hook
Configure]
subgraph "Plugin Files"
H[metadata.lua]
I[hooks/available.lua]
J[hooks/pre_install.lua]
K[hooks/env_keys.lua]
L[hooks/post_install.lua]
end
style C fill:#e1f5fe
style D fill:#e8f5e8
style E fill:#e8f5e8
style F fill:#e8f5e8
style G fill:#e8f5e8
These hooks must be implemented for a functional plugin:
Lists all available versions of the tool:
-- hooks/available.lua
function PLUGIN:Available(ctx)
local args = ctx.args -- User arguments
-- Return array of available versions
return {
{
version = "20.0.0",
note = "Latest"
},
{
version = "18.18.0",
note = "LTS",
addition = {
{
name = "npm",
version = "9.8.1"
}
}
}
}
end
For tools that have rolling releases like "nightly" or "stable" where the version string stays the same but the content changes, you can mark versions as rolling and provide a checksum for update detection:
function PLUGIN:Available(ctx)
return {
{
version = "nightly",
note = "Latest development build",
rolling = true, -- Mark as rolling release
checksum = "abc123..." -- SHA256 of the release asset
},
{
version = "stable",
note = "Latest stable release",
rolling = true,
checksum = "def456..."
},
{
version = "1.0.0",
note = "Fixed release"
-- No rolling or checksum needed for fixed versions
}
}
end
When rolling = true is set:
mise upgrade will check if the checksum has changed to detect updatesmise upgrade --bump will preserve the version name (e.g., "nightly") instead of converting it to a semverThe checksum should be the SHA256 hash of the release asset for the user's platform. See the vfox-neovim plugin for a complete example.
Handles pre-installation logic and returns download information:
-- hooks/pre_install.lua
function PLUGIN:PreInstall(ctx)
local version = ctx.version
local runtimeVersion = ctx.runtimeVersion
-- Determine download URL and checksums
local url = "https://nodejs.org/dist/v" .. version .. "/node-v" .. version .. "-linux-x64.tar.gz"
return {
version = version,
url = url,
sha256 = "abc123...", -- Optional checksum
note = "Installing Node.js " .. version,
-- Optional attestation metadata, choose a verification type
attestation = {
-- GitHub
github_owner = "ownername"
github_repo = "reponame"
-- Cosign
cosign_sig_or_bundle_path = "/path/to/sig/or/bundle/file"
-- SLSA
slsa_provenance_path = "/path/to/provenance/file"
},
-- Additional files can be specified
addition = {
{
name = "npm",
url = "https://registry.npmjs.org/npm/-/npm-" .. npm_version .. ".tgz"
}
}
}
end
Configures environment variables for the installed tool:
-- hooks/env_keys.lua
function PLUGIN:EnvKeys(ctx)
local mainPath = ctx.path
local runtimeVersion = ctx.runtimeVersion
local sdkInfo = ctx.sdkInfo['nodejs']
local path = sdkInfo.path
local version = sdkInfo.version
local name = sdkInfo.name
return {
{
key = "NODE_HOME",
value = mainPath
},
{
key = "PATH",
value = mainPath .. "/bin"
},
-- Multiple PATH entries are automatically merged
{
key = "PATH",
value = mainPath .. "/lib/node_modules/.bin"
}
}
end
These hooks provide additional functionality:
Performs additional setup after installation:
-- hooks/post_install.lua
function PLUGIN:PostInstall(ctx)
local rootPath = ctx.rootPath
local runtimeVersion = ctx.runtimeVersion
local sdkInfo = ctx.sdkInfo['nodejs']
local path = sdkInfo.path
local version = sdkInfo.version
-- Compile native modules, set permissions, etc.
local result = os.execute("chmod +x " .. path .. "/bin/*")
if result ~= 0 then
error("Failed to set permissions")
end
-- No return value needed
end
Modifies version before use:
-- hooks/pre_use.lua
function PLUGIN:PreUse(ctx)
local version = ctx.version
local previousVersion = ctx.previousVersion
local installedSdks = ctx.installedSdks
local cwd = ctx.cwd
local scope = ctx.scope -- global/project/session
-- Optionally modify the version
if version == "latest" then
version = "20.0.0" -- Resolve to specific version
end
return {
version = version
}
end
Parses version files from other tools:
-- hooks/parse_legacy_file.lua
function PLUGIN:ParseLegacyFile(ctx)
local filename = ctx.filename
local filepath = ctx.filepath
local versions = ctx:getInstalledVersions()
-- Read and parse the file
local file = require("file")
local content = file.read(filepath)
local version = content:match("v?([%d%.]+)")
return {
version = version
}
end
The easiest way to create a new tool plugin is to use the mise-tool-plugin-template repository as a starting point:
# Clone the template
git clone https://github.com/jdx/mise-tool-plugin-template my-tool-plugin
cd my-tool-plugin
# Remove the template's git history and start fresh
rm -rf .git
git init
# Customize the plugin for your tool
# Edit metadata.lua, hooks/*.lua files, etc.
The template includes:
.luacheckrc, stylua.toml)Create a directory with this structure (or use the template above):
my-tool-plugin/
├── metadata.lua # Plugin metadata and configuration
├── hooks/ # Hook functions directory
│ ├── available.lua # List available versions [required]
│ ├── pre_install.lua # Pre-installation hook [required]
│ ├── env_keys.lua # Environment configuration [required]
│ ├── post_install.lua # Post-installation hook [optional]
│ ├── pre_use.lua # Pre-use hook [optional]
│ └── parse_legacy_file.lua # Legacy file parser [optional]
├── lib/ # Shared library code [optional]
│ └── helper.lua # Helper functions
└── test/ # Test scripts [optional]
└── test.sh
Configure plugin metadata and legacy file support:
-- metadata.lua
PLUGIN = {
name = "nodejs",
version = "1.0.0",
description = "Node.js runtime environment",
author = "Plugin Author",
-- Legacy version files this plugin can parse
legacyFilenames = {
'.nvmrc',
'.node-version'
},
-- Tools whose bin paths should be available during install hooks
depends = { "node" },
}
Add depends to the PLUGIN table when install hooks need other mise-managed tools on PATH. Use tool names as they would appear in mise.toml, for example depends = { "go", "make" }. Omit it if hooks do not shell out to other tools.
This is separate from depends in [tools], which only makes one configured tool wait for another configured tool in the install graph. vfox metadata.lua depends is plugin metadata; when matching tools are configured, mise uses it to order current install jobs and to build the hook environment.
Create shared functions in the lib/ directory:
-- lib/helper.lua
local M = {}
function M.get_arch()
-- Use the RUNTIME object provided by vfox/mise
return (RUNTIME.archType == "amd64") and "x64" or RUNTIME.archType -- return as-is for other architectures
end
function M.get_os()
-- Use the RUNTIME object provided by vfox/mise
return (RUNTIME.osType == "windows") and "win" or RUNTIME.osType
end
function M.get_platform()
return M.get_os() .. "-" .. M.get_arch()
end
return M
Here's a complete example based on the vfox-nodejs plugin that demonstrates all the concepts:
-- hooks/available.lua
function PLUGIN:Available(ctx)
local http = require("http")
local json = require("json")
-- Fetch versions from Node.js API
local resp, err = http.get({
url = "https://nodejs.org/dist/index.json"
})
if err ~= nil then
error("Failed to fetch versions: " .. err)
end
local versions = json.decode(resp.body)
local result = {}
for i, v in ipairs(versions) do
local version = v.version:gsub("^v", "") -- Remove 'v' prefix
local note = nil
if v.lts then
note = "LTS"
end
table.insert(result, {
version = version,
note = note,
addition = {
{
name = "npm",
version = v.npm
}
}
})
end
return result
end
-- hooks/pre_install.lua
function PLUGIN:PreInstall(ctx)
local version = ctx.version
-- Determine platform using RUNTIME object
local arch_token = (RUNTIME.archType == "amd64") and "x64" or RUNTIME.archType
local os_token = (RUNTIME.osType == "windows") and "win" or RUNTIME.osType
local platform = os_token .. "-" .. arch_token
local extension = (RUNTIME.osType == "windows") and "zip" or "tar.gz"
-- Build download URL
local filename = "node-v" .. version .. "-" .. platform .. "." .. extension
local url = "https://nodejs.org/dist/v" .. version .. "/" .. filename
-- Fetch checksum
local http = require("http")
local shasums_url = "https://nodejs.org/dist/v" .. version .. "/SHASUMS256.txt"
local resp, err = http.get({ url = shasums_url })
local sha256 = nil
if err == nil then
-- Extract SHA256 for our file
for line in resp.body:gmatch("[^\n]+") do
if line:match(filename) then
sha256 = line:match("^(%w+)")
break
end
end
end
return {
version = version,
url = url,
sha256 = sha256,
note = "Installing Node.js " .. version .. " (" .. platform .. ")"
}
end
-- hooks/env_keys.lua
function PLUGIN:EnvKeys(ctx)
local mainPath = ctx.path
local os_type = RUNTIME.osType
local env_vars = {
{
key = "NODE_HOME",
value = mainPath
},
{
key = "PATH",
value = mainPath .. "/bin"
}
}
-- Add npm global modules to PATH
local npm_global_path = mainPath .. "/lib/node_modules/.bin"
if os_type == "windows" then
npm_global_path = mainPath .. "/node_modules/.bin"
end
table.insert(env_vars, {
key = "PATH",
value = npm_global_path
})
return env_vars
end
-- hooks/post_install.lua
function PLUGIN:PostInstall(ctx)
local sdkInfo = ctx.sdkInfo['nodejs']
local path = sdkInfo.path
-- Set executable permissions on Unix systems
if RUNTIME.osType ~= "windows" then
os.execute("chmod +x " .. path .. "/bin/*")
end
-- Create npm cache directory
local npm_cache_dir = path .. "/.npm"
os.execute("mkdir -p " .. npm_cache_dir)
-- Configure npm to use local cache
local npm_cmd = path .. "/bin/npm"
if RUNTIME.osType == "windows" then
npm_cmd = path .. "/npm.cmd"
end
os.execute(npm_cmd .. " config set cache " .. npm_cache_dir)
os.execute(npm_cmd .. " config set prefix " .. path)
end
-- hooks/parse_legacy_file.lua
function PLUGIN:ParseLegacyFile(ctx)
local filename = ctx.filename
local filepath = ctx.filepath
local file = require("file")
-- Read file content
local content = file.read(filepath)
if not content then
error("Failed to read " .. filepath)
end
-- Parse version from different file formats
local version = nil
if filename == ".nvmrc" then
-- .nvmrc can contain version with or without 'v' prefix
version = content:match("v?([%d%.]+)")
elseif filename == ".node-version" then
-- .node-version typically contains just the version number
version = content:match("([%d%.]+)")
end
-- Remove any whitespace
if version then
version = version:gsub("%s+", "")
end
return {
version = version
}
end
# Link your plugin for development
mise plugin link my-tool /path/to/my-tool-plugin
# Test listing versions
mise ls-remote my-tool
# Test installation
mise install [email protected]
# Test environment setup
mise use [email protected]
my-tool --version
# Test legacy file parsing (if applicable)
echo "2.0.0" > .my-tool-version
mise use my-tool
If you're using the template repository, you can run the included tests:
# Run linting
mise run lint
# Run tests
mise run test
Use debug mode to see detailed plugin execution:
mise --debug install [email protected]
Create a comprehensive test script:
#!/bin/bash
# test/test.sh
set -e
echo "Testing nodejs plugin..."
# Install the plugin
mise plugin install nodejs .
# Test basic functionality
mise install [email protected]
mise use [email protected]
# Verify installation
node --version | grep "18.18.0"
npm --version
# Test legacy file support
echo "20.0.0" > .nvmrc
mise use nodejs
node --version | grep "20.0.0"
# Clean up
rm -f .nvmrc
mise plugin remove nodejs
echo "All tests passed!"
Always provide meaningful error messages:
function PLUGIN:Available(ctx)
local http = require("http")
local resp, err = http.get({
url = "https://api.example.com/versions"
})
if err ~= nil then
error("Failed to fetch versions from API: " .. err)
end
if resp.status_code ~= 200 then
error("API returned status " .. resp.status_code .. ": " .. resp.body)
end
-- Process response...
end
Handle different operating systems properly using the RUNTIME object:
-- lib/platform.lua
local M = {}
function M.is_windows()
return RUNTIME.osType == "windows"
end
function M.get_exe_extension()
return M.is_windows() and ".exe" or ""
end
function M.get_path_separator()
return M.is_windows() and "\\" or "/"
end
return M
Note: The RUNTIME object is automatically available in all plugin hooks and provides:
RUNTIME.osType: Operating system type ("windows", "linux", "darwin")RUNTIME.archType: Architecture ("amd64", "arm64", "x86", etc.)RUNTIME.envType: libc environment type ("gnu" on glibc Linux, "musl" on musl Linux, nil on Windows/macOS and undetected systems)RUNTIME.version: vfox runtime versionRUNTIME.pluginDirPath: Plugin directory pathNormalize versions consistently:
local function normalize_version(version)
-- Remove 'v' prefix if present
version = version:gsub("^v", "")
-- Remove pre-release suffixes
version = version:gsub("%-.*", "")
return version
end
Cache expensive operations:
-- Cache versions for 12 hours
local cache = {}
local cache_ttl = 12 * 60 * 60 -- 12 hours in seconds
function PLUGIN:Available(ctx)
local now = os.time()
-- Check cache first
if cache.versions and cache.timestamp and (now - cache.timestamp) < cache_ttl then
return cache.versions
end
-- Fetch fresh data
local versions = fetch_versions_from_api()
-- Update cache
cache.versions = versions
cache.timestamp = now
return versions
end
Different installation logic based on platform or version:
function PLUGIN:PreInstall(ctx)
local version = ctx.version
-- Different logic for different platforms using RUNTIME object
if RUNTIME.osType == "windows" then
-- Windows-specific installation
return install_windows(version)
elseif RUNTIME.osType == "darwin" then
-- macOS-specific installation
return install_macos(version)
else
-- Linux installation
return install_linux(version)
end
end
For plugins that need to compile from source:
-- hooks/post_install.lua
function PLUGIN:PostInstall(ctx)
local sdkInfo = ctx.sdkInfo['tool-name']
local path = sdkInfo.path
local version = sdkInfo.version
-- Change to source directory
local build_dir = path .. "/src"
-- Configure build
local configure_result = os.execute("cd " .. build_dir .. " && ./configure --prefix=" .. path)
if configure_result ~= 0 then
error("Configure failed")
end
-- Compile
local make_result = os.execute("cd " .. build_dir .. " && make -j$(nproc)")
if make_result ~= 0 then
error("Compilation failed")
end
-- Install
local install_result = os.execute("cd " .. build_dir .. " && make install")
if install_result ~= 0 then
error("Installation failed")
end
end
Complex environment variable setup:
function PLUGIN:EnvKeys(ctx)
local mainPath = ctx.path
local version = ctx.sdkInfo['tool-name'].version
local env_vars = {
-- Standard environment variables
{
key = "TOOL_HOME",
value = mainPath
},
{
key = "TOOL_VERSION",
value = version
},
-- PATH entries
{
key = "PATH",
value = mainPath .. "/bin"
},
{
key = "PATH",
value = mainPath .. "/scripts"
},
-- Library paths
{
key = "LD_LIBRARY_PATH",
value = mainPath .. "/lib"
},
{
key = "PKG_CONFIG_PATH",
value = mainPath .. "/lib/pkgconfig"
}
}
-- Platform-specific additions
if RUNTIME.osType == "darwin" then
table.insert(env_vars, {
key = "DYLD_LIBRARY_PATH",
value = mainPath .. "/lib"
})
end
return env_vars
end