# Tool Plugin Development

::: tip
The [mise-tool-plugin-template](https://github.com/jdx/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.

## 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 are implemented in Lua (version 5.1 at the moment). They use a hook-based architecture with specific functions for different lifecycle events:

```mermaid
graph TD
    A[User Request] --> B[mise CLI]
    B --> C[Tool Plugin]

    C --> D[Available Hook<br/>List Versions]
    C --> E[PreInstall Hook<br/>Download]
    C --> F[PostInstall Hook<br/>Setup]
    C --> G[EnvKeys Hook<br/>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
```

## 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
```

##### Rolling Releases

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:

```lua
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 updates
- `mise upgrade --bump` will preserve the version name (e.g., "nightly") instead of converting it to a semver

The checksum should be the SHA256 hash of the release asset for the user's platform. See the [vfox-neovim plugin](https://github.com/mise-plugins/vfox-neovim) for a complete example.

#### 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,
        -- 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
```

#### 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](https://github.com/jdx/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'
    },

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

#### System Dependencies

Plugins that compile from source (or otherwise rely on system libraries and build tools) can declare those prerequisites with `systemDependencies`. Before installing the tool, mise checks each one and — depending on the [`system_deps`](/configuration/settings.html#system_deps) setting — reports, offers to install, or auto-installs anything missing.

```lua
PLUGIN = {
    name = "php",
    version = "1.0.0",

    systemDependencies = {
        -- an executable on PATH, with an optional version constraint
        { bin = "bison", version = ">=3.0",
          packages = { brew = "bison", apt = "bison", dnf = "bison" } },
        { bin = "re2c",
          packages = { brew = "re2c", apt = "re2c", dnf = "re2c" } },

        -- a library discoverable via pkg-config
        { pkgconfig = "libxml-2.0",
          packages = { brew = "libxml2", apt = "libxml2-dev", dnf = "libxml2-devel" } },
        { pkgconfig = "openssl",
          packages = { brew = "openssl@3", apt = "libssl-dev", dnf = "openssl-devel" } },

        -- a runtime shared library, by soname (Linux)
        { sharedlib = "libaio.so.1",
          packages = { apt = "libaio1", dnf = "libaio" } },

        -- an escape hatch: any shell command whose exit status 0 means "satisfied"
        { command = "xcode-select -p", optional = "macOS command line tools" },
    },
}
```

Each entry must set **exactly one** check:

| Check       | Detection                                          | Use for                                    |
| ----------- | -------------------------------------------------- | ------------------------------------------ |
| `bin`       | executable resolvable on `PATH`                    | compilers, build tools, `*-config` scripts |
| `pkgconfig` | `pkg-config --exists <name>`                       | C libraries that ship a `.pc` file         |
| `sharedlib` | dynamic linker can resolve the soname (Linux only) | runtime libraries for prebuilt binaries    |
| `command`   | the shell command exits `0`                        | anything the above can't express           |

Optional fields:

- **`version`** — a constraint (`>=3.0`, `>3`, `<=1.2`, `=3.0`, or a bare `3.0` meaning `>=3.0`) for `bin` and `pkgconfig`. mise runs `<bin> --version` / `pkg-config --modversion` and compares. If a version can't be extracted, the dependency is treated as satisfied (presence is enough) rather than blocking the install.
- **`optional`** — a short reason string. Missing optional dependencies never prompt or fail; they surface as a single informational line, letting users build without features they don't need (e.g. Erlang's `wxWidgets` GUI).
- **`packages`** — a map of package-manager name (`brew`, `brew-cask`, `apt`, `dnf`, `pacman`, `apk`, `flatpak`, `flatpak-user`, `mas`) to the package that provides the capability.

**Detection is the source of truth.** A check that passes is satisfied no matter how the capability was installed — Homebrew, apt, nix, MacPorts, or from source all pass without ceremony, and mise never asks _how_ it got there. The `packages` map is only consulted to _offer_ installing the missing subset; it is a remediation hint, not a declaration that the tool must come from that package manager.

These declarations are inert on older mise versions and on upstream vfox (both ignore unknown `PLUGIN` fields), so adding them is backward-compatible.

### 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
    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
```

## 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 = (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
```

### 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", "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 version
- `RUNTIME.pluginDirPath`: Plugin directory path

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

- [Start with the plugin template](https://github.com/jdx/mise-tool-plugin-template)
- [Learn about Backend Plugin Development](backend-plugin-development.md)
- [Explore available Lua modules](plugin-lua-modules.md)
- [Publishing your plugin](plugin-publishing.md)
- [View the vfox-nodejs plugin source](https://github.com/version-fox/vfox-nodejs)
