Skip to content

Tool Plugin Development

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.

What are Tool Plugins?

Tool plugins use traditional hook functions to manage a single tool. They provide:

  • Standard vfox Compatibility: Works with both mise and vfox
  • Complex Installation Logic: Handle source compilation, custom builds, and complex setups
  • Environment Configuration: Set up complex environment variables beyond just PATH
  • Legacy File Support: Parse version files from other tools (.nvmrc, .tool-version, etc.)
  • Cross-Platform Support: Works on Windows, macOS, and Linux

Plugin Architecture

Tool plugins use a hook-based architecture with specific functions for different lifecycle events:

Hook Functions

Required Hooks

These hooks must be implemented for a functional plugin:

Available Hook

Lists all available versions of the tool:

lua
-- 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

PreInstall Hook

Handles pre-installation logic and returns download information:

lua
-- 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,
        -- Additional files can be specified
        addition = {
            {
                name = "npm",
                url = "https://registry.npmjs.org/npm/-/npm-" .. npm_version .. ".tgz"
            }
        }
    }
end

EnvKeys Hook

Configures environment variables for the installed tool:

lua
-- 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

Optional Hooks

These hooks provide additional functionality:

PostInstall Hook

Performs additional setup after installation:

lua
-- 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

PreUse Hook

Modifies version before use:

lua
-- 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

ParseLegacyFile Hook

Parses version files from other tools:

lua
-- 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

Creating a Tool Plugin

Using the Template Repository

The easiest way to create a new tool plugin is to use the mise-tool-plugin-template repository as a starting point:

bash
# 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:

  • Pre-configured plugin structure with all required hooks
  • Example implementations with comments
  • Linting configuration (.luacheckrc, stylua.toml)
  • Testing setup with mise tasks
  • GitHub Actions workflow for CI

1. Plugin Structure

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

2. metadata.lua

Configure plugin metadata and legacy file support:

lua
-- 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'
    }
}

3. Helper Libraries

Create shared functions in the lib/ directory:

lua
-- lib/helper.lua
local M = {}

function M.get_arch()
    -- Use the RUNTIME object provided by vfox/mise
    local arch = RUNTIME.archType
    if arch == "amd64" then
        return "x64"
    elseif arch == "386" then
        return "x86"
    elseif arch == "arm64" then
        return "arm64"
    else
        return arch  -- return as-is for other architectures
    end
end

function M.get_os()
    -- Use the RUNTIME object provided by vfox/mise
    local os = RUNTIME.osType
    if os == "Windows" then
        return "win"
    elseif os == "Darwin" then
        return "darwin"
    else
        return "linux"
    end
end

function M.get_platform()
    return M.get_os() .. "-" .. M.get_arch()
end

return M

Real-World Example: vfox-nodejs

Here's a complete example based on the vfox-nodejs plugin that demonstrates all the concepts:

Available Hook Example

lua
-- 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

PreInstall Hook Example

lua
-- hooks/pre_install.lua
function PLUGIN:PreInstall(ctx)
    local version = ctx.version

    -- Determine platform using RUNTIME object
    local arch_token
    if RUNTIME.archType == "amd64" then
        arch_token = "x64"
    elseif RUNTIME.archType == "386" then
        arch_token = "x86"
    elseif RUNTIME.archType == "arm64" then
        arch_token = "arm64"
    else
        arch_token = RUNTIME.archType
    end
    local os_token
    if RUNTIME.osType == "Windows" then
        os_token = "win"
    elseif RUNTIME.osType == "Darwin" then
        os_token = "darwin"
    else
        os_token = "linux"
    end
    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

EnvKeys Hook Example

lua
-- 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

PostInstall Hook Example

lua
-- 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

Legacy File Support

lua
-- 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

Testing Your Plugin

Local Development

bash
# 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 my-tool@1.0.0

# Test environment setup
mise use my-tool@1.0.0
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:

bash
# Run linting
mise run lint

# Run tests
mise run test

Debug Mode

Use debug mode to see detailed plugin execution:

bash
mise --debug install nodejs@20.0.0

Plugin Test Script

Create a comprehensive test script:

bash
#!/bin/bash
# test/test.sh
set -e

echo "Testing nodejs plugin..."

# Install the plugin
mise plugin install nodejs .

# Test basic functionality
mise install nodejs@18.18.0
mise use nodejs@18.18.0

# 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!"

Best Practices

Error Handling

Always provide meaningful error messages:

lua
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

Platform Detection

Handle different operating systems properly using the RUNTIME object:

lua
-- 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", "386", etc.)

Version Normalization

Normalize versions consistently:

lua
local function normalize_version(version)
    -- Remove 'v' prefix if present
    version = version:gsub("^v", "")
    
    -- Remove pre-release suffixes
    version = version:gsub("%-.*", "")
    
    return version
end

Caching

Cache expensive operations:

lua
-- 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

Advanced Features

Conditional Installation

Different installation logic based on platform or version:

lua
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

Source Compilation

For plugins that need to compile from source:

lua
-- 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

Environment Configuration

Complex environment variable setup:

lua
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

Next Steps

Licensed under the MIT License. Maintained by @jdx and friends.