juniorsundar/cling.nvim

github github
command-line
stars 25
issues 1
subscribers 0
forks 3
CREATED

UPDATED


cling.nvim

GitHub Workflow Status Lua

cling.nvim implements a customisable and thin CLI wrapper around executable binaries in Neovim.

It can be used to quickly execute terminal commands:

  • without leaving the Neovim context via multiplexing or Ctrl+z,
  • without losing the text formatting of the command outputs,
  • and also to interact with the output of those commands as a text-buffer.

The plugin can also be configured to wrap CLI commands that you commonly use (like jj, docker, etc.) and:

  • automatically generate tab-completions in Neovim,
  • implement custom keymaps for those wrapped CLI output buffers,
  • automatically close the terminal buffer when the process exits (useful for interactive TUI tools).

[!NOTE]

Autogenerating tab-completions in Neovim is an experimental feature.

It may not work for all available CLI tools as there is no standard way to implement subcommands and completion functions in Bash. If such as instance is encountered, please raise an Issue ticket.

Requirements

  • Neovim 0.10+
  • bash-completion: Many CLI tools' completion scripts depend on this.
  • bash (obviously)
  • curl (optional) for fetching remote completion scripts (Method 4).

Installation

Using lazy.nvim:

return {
    "juniorsundar/cling.nvim",
    config = function()
        require("cling").setup({
            wrappers = {
                {
                    binary = "jj",
                    command = "JJ",
                    completion_cmd = "jj util completion bash",
                },
                -- {}, ...
            }
        })
    end,
}

Usage

The Cling Command

The plugin exposes the global :Cling command, which serves as a generic entry point for executing shell commands within the plugin's environment:

  • :Cling: Opens an input prompt to enter a shell command interactively.
  • :Cling with-env: Executes command with an .env file assigned interactively.
  • :Cling last: Executes the last executed command with .env.
  • :Cling -- <command>: Executes the command, treating everything after -- as the command string. This defaults to executing in current working directory.

Output Buffer Keymaps

When a command is executed, the output is displayed in a dedicated terminal-filetype buffer. The following default keymaps are available:

  • q: Closes the Cling window.
  • <CR> (Enter): Smart file navigation. If the cursor is on a file path (common in grep, ls, or compiler output), pressing Enter will attempt to open that file in the previous window. It supports file:line:col formats to jump directly to the specific location.
  • ge: Export the terminal output to a file. ANSI escape codes are stripped and metadata (command, CWD, timestamp) is appended as comments.

Split Modes

The :Cling command (and all wrapper commands) respect Neovim's built-in command modifiers for controlling split direction:

Modifier Result
:Cling -- ls Bottom horizontal split (default)
:vert Cling -- ls Vertical split
:tab Cling -- ls New tab
:top Cling -- ls Top horizontal split
:bot Cling -- ls Bottom horizontal split (explicit)

These modifiers work with wrapper commands as well:

:vert JJ log
:tab Docker ps

Exporting Output

You can export the terminal output from any Cling buffer to a file by pressing ge in normal mode while in the output buffer. The export will:

  • Prompt you for a file path (defaults to cling-output.log in CWD)
  • Append a metadata footer as vim modeline comments:
-- Command: echo hello
-- CWD: /home/user/project
-- Timestamp: 2026-03-06T12:00:00Z
-- vim: ft=log

Filename Expansion

Cling passes commands to the shell literally by default. %, #, <, and > keep their normal shell meanings, so existing commands keep working as before.

To expand a filename or cursor value, prefix the token with @:

cat @%               →  cat /path/to/current.lua
grep @<cWORD> src/   →  grep WordUnderCursor src/

Cling consumes @ only when it is directly before a supported token. Other uses of @ pass through unchanged. Use @@ for a literal @:

echo me@example.com  →  echo me@example.com
echo "type @@%"      →  echo "type @%"

Supported Tokens

Marker Expands to
@% the current file
@# the alternate file
@#N the file for buffer N
@<cword> the word under the cursor
@<cWORD> the WORD under the cursor. Unlike <cword>, it keeps punctuation such as foo.bar.
@<cfile> the file path under the cursor

Modifiers

Add modifiers after a token to transform its value:

  • Path: :p (full path), :~ (relative to $HOME), :. (relative to the CWD)
  • Anatomy: :h (directory), :t (basename), :r (name without extension), :e (extension)
  • Shell-safety: :S / :q (shell-escape the value)

Modifiers run from left to right. Given a current file of /home/user/proj/src/cling.lua:

@%          →  /home/user/proj/src/cling.lua
@%:h        →  /home/user/proj/src
@%:t        →  cling.lua
@%:t:r      →  cling
@%:e        →  lua
@%:p:h:t    →  src

Substitution modifiers (:s///, :gs///) are not supported. A marked token using either form passes through unchanged.

Execution CWD

Cling resolves paths against the execution CWD selected at the prompt, not the editor's current directory. In particular, @%:. is relative to the directory where Cling runs the command.

Passthrough and Escaping

Unmarked %, #, <, >, and backticks keep their shell meanings. These commands are unchanged:

printf '%s\n' hi
make x # note
sort < a > b
echo `date`

Wrapper Commands

Expansion applies to commands entered through the Cling prompt, :Cling -- ..., and replayed history. Wrapper commands such as :JJ and :Docker do not expand markers; their arguments stay literal.

Configuration

You can define custom wrappers for your CLI tools in the setup function. Wrappers allow you to create specific Neovim user commands (e.g., :JJ, :Docker) with autocompletions that can either be derived from the CLI tool itself, or from the completion bash file.

Setup Options

Option Type Description
separate_history boolean Enable per-CWD command history. When true (default), commands are grouped by working directory and persisted to stdpath("data")/cling/history/. When false, uses Neovim's native input history for the command prompt.

Wrapper Fields

Field Type Description
binary string The binary (or shell command string) to execute.
command string The Neovim user command name to register (e.g. "Lazygit").
help_cmd string Flag passed to the binary to crawl help output for completions.
completion_cmd string Shell command that outputs a Bash completion script.
completion_file string Path or URL to an existing Bash completion script.
keymaps fun(buf: integer) Callback to define buffer-local keymaps for the output buffer.
close_on_exit boolean If true, the terminal buffer is automatically wiped when the process exits. Defaults to false.
cwd string|fun(): string Working directory for the command. Can be a static string or a function evaluated at invocation time. Defaults to vim.fn.getcwd().
no_history boolean If true, running this wrapper does not update :Cling's last command history. Defaults to true for all wrappers - set to false to opt a wrapper back into history.

[!TIP]

close_on_exit = true is ideal for interactive fullscreen TUI tools (e.g. lazygit, yazi) whose terminal buffer has no useful output to read after exit. Leave it false (the default) for output-producing commands where you want to scroll, search, or export the results afterwards.

Completion Generation

cling.nvim provides 4 ways to generate subcommands and completions for your wrappers:

  1. Help Crawling (help_cmd):
    • Recursively runs the binary with a help flag (e.g., --help) to parse subcommands and flags.
    • Best for: Tools that don't provide bash completion scripts but have structured help output.
  2. Completion Command (completion_cmd):
    • Executes a specific command that outputs a Bash completion script, which is then parsed by the plugin.
    • Best for: Modern tools that can generate their own shell completions (e.g., cobra-based CLIs).
  3. Local Completion File (completion_file):
    • Points to an existing Bash completion script on your local filesystem.
    • Best for: Standard system tools where the completion file is already installed (e.g., /usr/share/bash-completion/completions/).
  4. Remote Completion File (completion_file as URL):
    • Points to a URL serving a raw Bash completion script. The plugin will curl this file.
    • Best for: Tools where you want to fetch the latest completions directly from the repository without manual installation.

Caching & Updates

To optimize performance, cling.nvim parses and caches the generated completions in stdpath("data")/cling/completions/<binary>.lua.

These cached files are loaded on subsequent startups to avoid expensive re-parsing. If you update the underlying CLI tool or want to refresh the completions, you can force a re-parse by passing the --reparse-completions flag to your wrapper command:

:<WrapperCommand> --reparse-completions

You can also manually add completions if you want as they are all just .lua files that export a table.

Examples

Wrapping Jujutsu (jj) with custom keymaps

This example shows how to wrap the Jujutsu VCS and to implement a custom keymap to send the outputs of jj show to a quickfix list.

It uses the completion_cmd method to generate completions dynamically.

return {
    "juniorsundar/cling.nvim",
    config = function()
        local function strip_ansi(str)
            return str:gsub("\27%[[0-9;]*m", "")
        end

        local function get_file_from_line(line)
            local clean = strip_ansi(line)
            local file = clean:match "^Modified regular file (.*):$"
            if file then
                return file, "Modified"
            end
            file = clean:match "^Added regular file (.*):$"
            if file then
                return file, "Added"
            end
            file = clean:match "^Removed regular file (.*):$"
            if file then
                return file, "Removed"
            end
            file = clean:match "^Renamed .* to (.*):$"
            if file then
                return file, "Renamed"
            end
            local _, b = clean:match "^diff %-%-git a/(.*) b/(.*)"
            if b then
                return b, "Git Diff"
            end
            return nil
        end

        local function populate_quickfix(buf)
            local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
            local qf_list = {}
            local current_file = nil
            local current_type = nil
            local last_was_gap = true

            for _, raw_line in ipairs(lines) do
                local line = strip_ansi(raw_line)
                local file, type = get_file_from_line(line)

                if file then
                    current_file = vim.trim(file)
                    current_type = type
                    last_was_gap = true
                elseif line:match "^%s*%.%.%.%s*$" then
                    last_was_gap = true
                elseif current_file then
                    local old, new = line:match "^%s*([0-9]*)%s+([0-9]*):"
                    if old or new then
                        if last_was_gap then
                            local lnum = tonumber(new) or tonumber(old) or 1
                            local text = line:sub((line:find ":" or 0) + 1)
                            table.insert(qf_list, {
                                filename = current_file,
                                lnum = lnum,
                                text = string.format("[%s] %s", current_type or "Change", vim.trim(text)),
                            })
                            last_was_gap = false
                        end
                    end
                end
            end

            if #qf_list > 0 then
                vim.fn.setqflist(qf_list, "r")
                vim.notify("Quickfix populated with " .. #qf_list .. " entries", vim.log.levels.INFO)
                vim.cmd "copen"
            else
                vim.notify("No file headers or hunks found", vim.log.levels.WARN)
            end
        end

        require("cling").setup {
            wrappers = {
                {
                    binary = "jj",
                    command = "JJ",
                    completion_cmd = "jj util completion bash",
                    keymaps = function(buf)
                        vim.keymap.set("n", "<C-q>", function()
                            populate_quickfix(buf)
                        end, { buffer = buf, silent = true, desc = "JJ: Move diffs to quickfix" })
                    end,
                },
            },
        }
    end,
}

Wrapping TUI tools with close_on_exit

For interactive fullscreen TUI tools like lazygit or yazi, the terminal buffer has no useful content once the tool exits. Setting close_on_exit = true wipes the buffer automatically so a Cling buffer doesn't persist with [Process exited 0].

Example: lazygit

lazygit is a terminal UI for git.

require("cling").setup {
    wrappers = {
        {
            binary = "lazygit",
            command = "Lazygit",
            help_cmd = "--help",
            close_on_exit = true,
        },
    },
}

vim.keymap.set("n", "<leader>GL", function()
    vim.cmd "Lazygit"
    vim.cmd "wincmd T"
    vim.cmd "startinsert"
end, { desc = "lazygit" })

Example: yazi

yazi is a terminal file manager. Because cling runs tools inside a Neovim terminal buffer, naively wrapping yazi would cause it to open files in a nested Neovim instance rather than the parent one.

The solution is to use yazi's built-in --chooser-file flag. Instead of opening files directly, yazi writes the selected path to a temp file on exit. A small inline shell script then reads that path and uses nvim --server "$NVIM" --remote to instruct the parent Neovim instance to open it via RPC. The $NVIM socket is automatically exposed by Neovim to all its terminal children.

require("cling").setup {
    wrappers = {
        {
            binary = [[sh -c 'f=$(mktemp); yazi --chooser-file="$f"; sel=$(cat "$f"); rm -f "$f"; [ -n "$sel" ] && nvim --server "$NVIM" --remote "$sel"']],
            command = "Yazi",
            close_on_exit = true,
            cwd = function()
                return vim.fn.expand "%:p:h"
            end,
        },
    },
}

vim.keymap.set("n", "<leader>o", function()
    vim.cmd "Yazi"
    vim.cmd "startinsert"
end, { desc = "Yazi (File Explorer)" })

Different methods of generating tab-completion

Generating tab-completions can be achieved through following 4 methods:

wrappers = {
    -- Method 1: Recursive Help Crawling
    {
        binary = "docker",
        command = "Docker",
        help_cmd = "--help",
    },

    -- Method 2: Completion Command
    {
        binary = "jj",
        command = "JJ",
        completion_cmd = "jj util completion bash",
    },

    -- Method 3: Local File
    {
        binary = "git",
        command = "Git",
        completion_file = "/usr/share/bash-completion/completions/git",
    },

    -- Method 4: Remote URL (requires curl)
    {
        binary = "eza",
        command = "Eza",
        completion_file = "https://raw.githubusercontent.com/eza-community/eza/main/completions/bash/eza",
    },
}