Yazi integration#

yazi is a terminal file manager with a Lua plugin system. olink maps well onto it as a thin wrapper plugin: the Lua side only discovers the project directory, shows a menu, and shells out to the olink binary. No URL logic is ported, so the plugin never drifts when new targets are added. Tested against yazi 26.9.1; the APIs used are ya.sync, ya.which, ya.notify, ya.clipboard, ya.emit, ya.quote, Command:output and fs.cha.

How it works#

  1. Yazi gives the plugin the current tab’s directory (cx.active.current.cwd).

  2. The plugin walks up to the project root — olink only works from the root — and runs olink list -d <root> to learn which targets exist there.

  3. The target names become a ya.which key menu (names always fit the menu’s column layout); the pick shows a confirm dialog with the full description and the resolved URL, and confirming runs olink open <target> -d <root> (or olink url + clipboard with --copy, no dialog).

Requirements#

  • The olink CLI on PATH.

  • A yazi new enough for ya.which (any recent release; 26.9.1 verified).

Install#

Install the plugin with yazi’s package manager (it lives in this repository’s olink.yazi/ directory) and bind it in ~/.config/yazi/keymap.toml:

ya pkg add hasansezertasan/olink:olink
[[mgr.prepend_keymap]]
on = ["g", "o"]
run = "plugin olink"
desc = "Open project links with olink"

[[mgr.prepend_keymap]]
on = ["g", "O"]
run = "plugin olink -- --copy"
desc = "Copy project link URL with olink"

The -- before --copy matters: Yazi only forwards arguments after the end-of-options separator to the plugin, so without it job.args.copy stays unset and g O would open instead of copying.

Press g o while browsing a project to open a link, g O to copy the URL instead. Outside a project (no root markers found) the plugin notifies instead of failing silently.

Plugin source#

The installed main.lua is the tested file olink.yazi/main.lua, included verbatim below — what you read here is what the tests execute:

olink.yazi/main.lua#
--- @since 26.9.1
-- ~/.config/yazi/plugins/olink.yazi/main.lua (installed by `ya pkg add`)
--
-- Open olink targets (PyPI page, issues, CI, download stats, ...) for the
-- project you are browsing, without leaving yazi.
-- Requires the `olink` CLI on PATH (https://github.com/hasansezertasan/olink).
-- Picks with the which-key menu, then confirms with the full description
-- and resolved URL. Everything stays inside yazi's own UI.

--- Files that mark a project root. Kept in sync with what olink reads
--- (git remotes plus the per-ecosystem package manifests). Exact names
--- only: glob-based manifests (`*.gemspec`, `*.csproj`, `*.cabal`) have no
--- existence check here and resolve through the `.git` marker instead.
local MARKERS = {
	".git",
	"pyproject.toml",
	"package.json",
	"Cargo.toml",
	"go.mod",
	"composer.json",
	"pubspec.yaml",
	"pom.xml",
	"mix.exs",
	"Makefile.PL",
	"dist.ini",
}

--- One keystroke per target in the which-key menu. Built from code ranges
--- rather than one long literal: a 36-character string trips secret
--- scanners, and this pool is just a-z then 0-9.
local KEY_POOL = (function()
	local keys = {}
	for code = 97, 122 do -- a-z
		keys[#keys + 1] = string.char(code)
	end
	for code = 48, 57 do -- 0-9
		keys[#keys + 1] = string.char(code)
	end
	return table.concat(keys)
end)()

--- Current tab's cwd; `cx` is only readable from a sync context.
local get_cwd = ya.sync(function()
	return tostring(cx.active.current.cwd)
end)

local function exists(url)
	local cha, _ = fs.cha(url)
	return cha ~= nil
end

--- Parent directory of `path`, or nil at the filesystem root. No depth
--- cap: every step shortens the path, so the walk always terminates.
local function parent_dir(path)
	local url = Url(path)
	local parent = url.parent
	if parent == nil or tostring(parent) == tostring(url) then
		return nil
	end
	return tostring(parent)
end

--- olink only works from the project root, so walk up from `start`
--- until a directory carries a known marker. (`parent` is yazi's own
--- Url property, so separators are correct on every platform.)
local function find_root(start)
	local dir = start
	while dir ~= nil do
		local url = Url(dir)
		for _, marker in ipairs(MARKERS) do
			if exists(url:join(marker)) then
				return dir
			end
		end
		dir = parent_dir(dir)
	end
	return nil
end

--- Run olink and return trimmed stdout, or nil plus an error message.
local function olink(args)
	local output, err = Command("olink"):arg(args):output()
	if output == nil then
		return nil, string.format("failed to start `olink`: %s", tostring(err))
	end
	if not output.status.success then
		local msg = output.stderr
		if msg == nil or msg == "" then
			msg = "olink exited with code " .. tostring(output.status.code)
		end
		return nil, msg
	end
	return (output.stdout:gsub("%s+$", "")), nil
end

--- Parse the human-readable `olink list` output into { name, desc } rows.
--- Each row looks like: `  pypi             - Open PyPI page`
local function parse_list(text)
	local rows = {}
	for line in text:gmatch("[^\r\n]+") do
		local name, desc = line:match("^%s+(%S+)%s+%-%s+(.+)$")
		if name ~= nil and desc ~= nil then
			rows[#rows + 1] = { name = name, desc = desc }
		end
	end
	return rows
end

local function fail(message)
	ya.notify({ title = "olink", content = message, level = "error", timeout = 5 })
end

--- Pick a target row with the which-key menu. Labels are names only: the
--- menu lays candidates out in columns, so each cell only fits a couple
--- dozen characters — names always fit, descriptions would chop mid-word.
--- The full description is shown on the confirm dialog instead.
--- Returns the row, or nil when dismissed. Beyond the key pool the menu
--- cannot show everything, so a notice names the shortfall honestly.
local function pick_row(rows)
	local cands = {}
	for i, row in ipairs(rows) do
		if i > #KEY_POOL then
			break
		end
		cands[i] = { on = KEY_POOL:sub(i, i), desc = row.name }
	end
	if #rows > #KEY_POOL then
		ya.notify({
			title = "olink",
			content = string.format("showing %d of %d targets", #KEY_POOL, #rows),
			timeout = 4,
		})
	end

	local idx = ya.which({ cands = cands })
	if idx == nil then
		return nil
	end
	return rows[idx]
end

--- Walk up to the nearest directory olink can actually list. A `.git`
--- directory is a repository boundary: if olink rejects it, that error is
--- reported rather than silently opening a parent repository's links.
--- Manifest-only directories that olink rejects are skipped upward; the
--- first error is kept for the final report. Note an empty list still exits
--- successfully, so success alone is not acceptance: zero parsed targets
--- counts as unusable too.
local function find_usable(start)
	local dir = start
	local first_err = nil
	while true do
		local root = find_root(dir)
		if root == nil then
			return nil, first_err or "no project root found above the current directory"
		end
		local text, lerr = olink({ "list", "-d", root })
		if text ~= nil and #parse_list(text) > 0 then
			return root, text
		end
		local uerr = lerr or ("no olink targets available for " .. root)
		if first_err == nil then
			first_err = uerr
		end
		if exists(Url(root):join(".git")) then
			return nil, uerr
		end
		dir = parent_dir(root)
	end
end

local function entry(self, job)
	local root, text = find_usable(get_cwd())
	if root == nil then
		fail(text)
		return
	end

	local rows = parse_list(text)

	local picked = pick_row(rows)
	if picked == nil then
		return
	end

	local url, uerr = olink({ "url", picked.name, "-d", root })
	if url == nil then
		fail(uerr)
		return
	end

	if job.args.copy then
		ya.clipboard(url)
		ya.notify({ title = "olink", content = "copied: " .. url, timeout = 3 })
		return
	end

	local open_it = ya.confirm({
		pos = { "center", w = 70, h = 10 },
		title = picked.name,
		body = picked.desc .. "\n" .. url,
	})
	if open_it then
		ya.emit("shell", { string.format("olink open %s -d %s", ya.quote(picked.name), ya.quote(root)) })
	end
end

return { entry = entry }

Testing#

tests/test_yazi_plugin.py keeps this page honest:

  • the literalinclude above must reference the example file, so docs and code cannot drift;

  • the TOML keymap snippet must parse;

  • the Lua file is compiled and its entry point is executed inside lupa (an in-process Lua runtime, so this runs on every CI OS with no system dependency) against live olink list / olink url output from a fixture project — covering the open path with its confirm dialog, dialog dismissal, --copy, menu dismissal, the no-project-root case, olink failure, the Windows root walk, retry past a broken manifest, retry past an empty target list, the .git retry boundary, the menu cap notice, and multi-ecosystem suffix passthrough.

Notes#

  • Picker: the which-key menu shows target names only, because its column layout chops longer labels mid-word and nothing may be shortened by the plugin. The full description and resolved URL appear on the confirm dialog instead — everything stays inside yazi’s own UI.

  • Why parse the text output? olink list --json exists, but decoding it in Lua needs either jq on PATH or a JSON library; the name - description lines parse with one pattern and keep the plugin dependency-free. If olink ever changes that text layout, switch the parse_list step to --json.

  • Multi-ecosystem projects: the default olink list emits suffixed names such as deps:npm when several ecosystems are detected, and the plugin passes listed names through verbatim, so they open as-is.

  • More targets than keys: the menu caps at the key pool (36 entries); beyond that it says how many it omits, so nothing vanishes silently.

  • Root markers: exact filenames only, including the Perl pair (Makefile.PL, dist.ini). Glob-based manifests (*.gemspec, *.csproj, *.cabal) have no existence check and resolve through the .git marker instead.

  • No TUI here: olink interactive is replaced by yazi’s own ya.which menu — the right trade, since yazi is the interface.