Zero-dependency package to print paths as OSC 8 hyperlinks.
Make the file paths your CLI prints ctrl/cmd-clickable
CLIs print paths, but making them open can be tricky.
Thankfully, OSC 8 hyperlinks exist. They point at an invisible absolute file:// URL.
clickable-path is a zero-dependency package to print paths as OSC 8 hyperlinks if your terminal supports them, falling back to a plain label if not.
npm install clickable-path
[!NOTE]
Requires Node 22.1 or newer, which is wherepathToFileURLgained thewindowsoption used to convert Windows-shaped paths on any platform.
import { link } from 'clickable-path'
console.log(`Building ${link('/home/me/project/nuxt.config.ts')}`)
// -> label `nuxt.config.ts`, linking file:///home/me/project/nuxt.config.ts
console.log(link('src/index.ts', { line: 12 }))
// -> label `src/index.ts:12`, linking file:///home/me/project/src/index.ts#L12
link(path, options?)Wraps path in an OSC 8 hyperlink pointing at it on disk, labelled with the cwd-relative form (plus :<line>:<column> if given) or with whatever options.formatter returns. Relative input is resolved against options.cwd.
createLinker(defaults?)Returns a { link } with defaults pre-applied, so you can configure a formatter and cwd once rather than at every call site:
const { link } = createLinker({
formatter: absolute => absolute.replace(rootDir, '~'),
})
console.log(link('/project/app/tailwind.config.ts'))
// label `~/tailwind.config.ts`, linking file:///project/app/tailwind.config.ts
The formatter receives the resolved absolute path, plus line and column if they were passed, and its return value is used without modification. (The default formatter is path.relative with a :<line>:<column> suffix.) Showing the position is up to the formatter; a label that omits it still links to it, so the file opens at the right line either way. Every LinkOptions key can be defaulted this way and overridden per call.
[!TIP]
Pad and align inside the formatter, not on the result. The returned string contains invisible escapes, solink(path).padEnd(20)pads to the wrong width, whileformatter: absolute => basename(absolute).padEnd(20)lines up as expected.
supportsHyperlinks(stream?)Whether stream (default process.stdout) will render hyperlinks.
The environment is read on every call rather than snapshotted at module load, so a .env file or a --no-color flag applied after import is still respected. It costs around a microsecond; if you are linking in a hot loop, call this once and pass the result as enabled.
LinkOptions| Option | Default | |
|---|---|---|
formatter |
cwd-relative path plus :<line>:<column> |
builds the label, given the absolute path, line and column |
cwd |
process.cwd(), read at call time |
base for relative paths |
stream |
process.stdout |
stream whose TTY state gates output |
line / column |
shown as :<line>:<column> and linked as #L<line>,<column> |
|
id |
OSC 8 id param, so a label wrapped across lines hovers as one link |
|
enabled |
detection result | force on/off |
Paths are converted with pathToFileURL, so spaces, #, ? and non-ASCII characters are percent-encoded. Windows-shaped inputs (C:\..., \\server\share\...) are converted on any platform.
[!NOTE]
No escapes are emitted when the target stream isn’t a TTY, or whenCIis set, as they would otherwise end up in log files. Netlify is the exception, since it renders build logs as HTML and never allocates a TTY.
Overrides, in order of precedence: FORCE_HYPERLINK (set to 0 to disable), --no-hyperlink / --hyperlink flags, then NO_COLOR / NO_HYPERLINK / NO_HYPERLINKS.
[!WARNING]
FORCE_HYPERLINK=1bypasses every check, including the CI and non-TTY ones. Escape sequences will end up in whatever you are redirecting to.
NO_COLOR disables hyperlinks here. Colour support and hyperlink support are not the same capability, so there is no general colour-support check, but someone who has asked for no escape sequences at all should get none.
| Terminal | Detection |
|---|---|
| Windows Terminal >= 1.4 | WT_SESSION (any other terminal on win32 is treated as unsupported) |
| VS Code >= 1.72 | TERM_PROGRAM=vscode + version |
| Cursor | TERM_PROGRAM=vscode + CURSOR_TRACE_ID (own 0.x version line) |
| iTerm2 >= 3.1 | TERM_PROGRAM=iTerm.app + version |
| WezTerm >= 20200620 | TERM_PROGRAM=WezTerm, including Nix’s 0-unstable-YYYY-MM-DD scheme |
| ghostty | TERM_PROGRAM=ghostty or TERM=xterm-ghostty |
| kitty | TERM=xterm-kitty |
| Alacritty >= 0.11 | TERM=alacritty |
| zed, rio, Tabby, Warp, Orca | TERM_PROGRAM |
| GNOME Terminal / VTE >= 0.50.1 | VTE_VERSION (0.50.0 is excluded: it segfaults on hyperlinks) |
| tmux >= 3.4 | TERM_PROGRAM=tmux + version |
Terminal.app is explicitly unsupported: it ignores OSC 8, though it degrades gracefully to the plain label. TeamCity is excluded. Anything unrecognised gets no escapes, since a wrong positive prints visible junk.
Passing line (and optionally column) appends :12:3 to the default label and #L12,3 to the URL. Most terminals ignore the fragment and simply open the file, so treat jump-to-line as a hint. You can decide whether to show a position in a custom formatter if you want.
[!IMPORTANT]
The comma in#L12,3is deliberate. VS Code parses the fragment with/^L?(\d+)(?:,(\d+))?/, so#L12:3matches the line and silently drops the column.
terminal-link and supports-hyperlinksterminal-link falls back to appending the raw URL (nuxt.config.ts file:///home/me/nuxt.config.ts), which is noise in a log file and breaks any width maths. Here the output is exactly what you would have printed anyway.TERM_PROGRAM with tmux, hiding the outer terminal, so supports-hyperlinks reports no support inside every tmux session. tmux itself has handled OSC 8 since 3.4, so that version and up are supported directly.supports-hyperlinks snapshots supportsHyperlinks.stdout at module load, so anything that mutates the environment after import is missed, such as loading a .env file, or normalising --no-color into NO_COLOR while parsing argv.supports-hyperlinks returns false whenever supports-color does, but this isn’t necessarily correct. (We still honour NO_COLOR if set.)id param is exposed, which terminal-link does not surface.terminal-link(text, url) makes every call site build both halves; here the path is the argument and the label comes from a formatter you configure once.supports-color + has-flag + ansi-escapes.supports-hyperlinks and terminal-link by Sindre Sorhus and James Talmage are excellent. Detection coverage in this package is informed by supports-hyperlinks, reimplemented so there are no dependencies and no module-load-time caching. If you want hyperlinks in general rather than paths specifically, terminal-link would be a good choice.corepack enablepnpm installpnpm devMade with ❤️
Published under MIT License.