A Claude Code mod: file paths in Claude's replies become links that open the file (or your editor at the line). macOS, Linux and Windows paths, relative ones…

<h1 align="center">open-links</h1>
<a href="https://github.com/helenkwok/open-links/actions/workflows/test.yml"><img alt="tests" src="https://github.com/helenkwok/open-links/actions/workflows/test.yml/badge.svg"></a> <img alt="version 0.2.0" src="https://img.shields.io/badge/version-0.2.0-d97757"> <img alt="Claude Code 2.1.287+" src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-d97757"> <img alt="TypeScript 7" src="https://img.shields.io/badge/TypeScript-7-3178c6"> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-3fb950"></a>
A Claude Code mod: file paths in Claude's replies become links that open the file, or your editor at the line. Claude Code 2.1.296 draws them as plain text (anthropics/claude-code#79839), so src/foo.ts:42 or F:\work\README.md in a reply cannot be opened from the transcript.
[!NOTE] The reply is drawn exactly as Claude wrote it. open-links adds one line under it, with a link for each path that exists. It only checks whether a path exists, writes nothing and makes no network call; it starts a process only when you click a link. Details: What it can touch.
The same reply, in the same folder, captured from two live sessions:
Claude Code 2.1.287 or later:
claude plugin marketplace add helenkwok/gsd-status-mod claude plugin install open-links@helenkwok-mods
The marketplace is shared with gsd-status-mod. Set the editor with /plugin configure open-links@helenkwok-mods. To try it for one session without installing:
git clone https://github.com/helenkwok/open-links ~/open-links claude --plugin-dir ~/open-links
/etc/hosts), home (~/x.md), Windows (F:\work\a.md, F:/work/a.md), relative to the session folder (src/foo.ts, ./b.ts), and bare file names there (README.md).runs/x/a.png after its tools ran in ~/data/project) is found through the folders this session's tools named, including earlier turns of a resumed session, or through the roots setting. It links only when exactly one file matches.:42 and :42:7 stay in the label; with an editor set, a click opens it at that line... are skipped.open on macOS, explorer on Windows, xdg-open on Linux), or any editor that takes -g file:line (code, cursor, windsurf)..planning is left to its reader links.| Option | What it does | Default |
|---|---|---|
editor | Command to open files at the line (code, cursor, windsurf: anything that takes -g file:line). Empty: the default app. | "" |
skipPlanning | Skip markdown under .planning, which gsd-status-mod already links. | true |
roots | Extra folders to resolve relative paths against, separated by ; (~ allowed). The folders this session's tools worked in are learned without it. | "" |
ui.render hook on each reply (AssistantMessage) picks out the words that look like paths, with their :line.$.fs.stat, at most once a minute per path. A relative path that is not there is tried under the roots, and under the folders named in this session's tool calls (noted as each call starts, and read back once from the conversation so a resumed session has them) and in replies; it is linked only if exactly one of those holds the file. Network locations (//host, /net, /Network) are never checked.⎿ open: …, carries a file:// link for each path that exists.file:// hyperlink your terminal may open itself.claude plugin validate .claude-plugin/plugin.json lists it:
ui.render for AssistantMessage, and tool.call, which only notes the paths in a call's arguments and passes the call on unchanged.$.fs.stat (does a path exist), $.session.cwd, $.session.messages (once, for the paths in earlier tool calls), $.env.get (HOME, USERPROFILE), $.clock.now, $.ui.resolve, $.ui.toast, and $.process.run only when you click a link.Nothing is clickable. Clicks reach a mod only in Claude Code's fullscreen terminal. Outside it, your terminal must open file:// hyperlinks itself (iTerm2 and Terminal.app use ⌘-click).
A path has no link. It is not in the session folder, the roots or a folder this session's tools named, it is in more than one of them (open-links does not guess), it climbs out with .., or it is markdown under .planning with skipPlanning on. Add the folder to roots to fix the first case.
Does it work on Windows? Windows paths are handled and unit-tested; a live run on Windows has not been done yet.
TypeScript 7, loaded by Claude Code as is (no build step). Node 24 or later.
npm ci npm test # node:test on tests/*.spec.ts (Node runs the .ts directly) npm run lint # oxlint npm run check # tsc (TypeScript 7) and oxlint's type-aware rules
npm run check needs .claude-plugin/types/, which Claude Code writes when it loads the mod from this folder (run claude --plugin-dir . once); those files are not in git, so CI runs the tests and the plain lint only. ESLint is not used: typescript-eslint does not support TypeScript 7 yet. A README test fails when a setting, its default, the version or an image falls out of step with the code.
Release notes: CHANGELOG.md. MIT, see LICENSE. Not affiliated with Anthropic.
hooks/open-links.ts 172 lines1import type { EngineInterface, PressedLink, Register } from "claude-code";
2
3// open-links: file paths in Claude's replies become links. Claude Code 2.1.296 draws them as plain text
4// (anthropics/claude-code#79839), so `src/foo.ts:42` or `F:\work\README.md` cannot be opened from the transcript.
5// The reply is drawn as written; a line under it carries one link per path that exists.
6
7// Path-like words in a reply, as written, with their line number when one follows. Web links are skipped.
8export type PathRef = { text: string; path: string; line?: number };
9export function pathRefs(reply: string | undefined): PathRef[] {
10 const out = new Map<string, PathRef>();
11 const words = (reply ?? "").replace(/[a-z][a-z0-9+.-]*:\/\/\S*/gi, " ").match(/[\w.~@+\\/:-]+/g) ?? [];
12 for (let w of words) {
13 w = w.replace(/^:+/, "").replace(/[.,;:]+$/, "");
14 const m = /^(.+?)(?::(\d+)(?::\d+)?)?$/.exec(w);
15 if (!m) continue;
16 const path = m[1] ?? "", line = m[2];
17 const abs = /^([A-Za-z]:[\\/]|\/|~[\\/])/.test(path);
18 const dotted = /[\w-]\.[A-Za-z0-9]{1,8}$/.test(path); // ends in a file extension
19 const slashed = /[\\/]/.test(path);
20 if (!(abs || (slashed && (dotted || path.endsWith("/"))) || (dotted && /[A-Za-z]/.test(path.split(".")[0] ?? "")))) continue;
21 if (/^\d+(\.\d+)+$/.test(path) || path.includes("..")) continue; // a version number; a path that climbs out
22 if (!out.has(w)) out.set(w, { text: w, path, ...(line ? { line: Number(line) } : {}) });
23 }
24 return [...out.values()];
25}
26
27// A path as written -> absolute, with "/" separators. home and cwd are absolute.
28export function absolute(path: string, cwd: string, home: string | undefined): string | null {
29 const p = path.replace(/\\/g, "/");
30 if (/^[A-Za-z]:\//.test(p) || p.startsWith("/")) return p;
31 if (p.startsWith("~/")) return home ? `${home.replace(/\\/g, "/")}/${p.slice(2)}` : null;
32 return `${cwd.replace(/\\/g, "/").replace(/\/$/, "")}/${p.replace(/^\.\//, "")}`;
33}
34
35// Absolute, home and Windows paths written anywhere in a text (a tool's input, a reply), with "/" separators and ~ expanded.
36export function pathsIn(text: string, home: string | undefined): string[] {
37 const out = new Set<string>();
38 const plain = text.replace(/[a-z][a-z0-9+.-]*:\/\/\S*/gi, " ");
39 for (const m of plain.matchAll(/(?<![\w.~-])(?:[A-Za-z]:[\\/]|~[\\/]|\/)[^\s'"`;|&<>(){}[\],]+/g)) {
40 let p = m[0].replace(/\\/g, "/").replace(/[.:]+$/, "");
41 if (p.startsWith("~/")) { if (!home) continue; p = `${home.replace(/\\/g, "/")}/${p.slice(2)}`; }
42 if (p.length > 1) out.add(p.replace(/\/+$/, ""));
43 }
44 return [...out];
45}
46
47// Where a relative path might live, besides the session folder: under each root, and under every folder a known path
48// passes through whose next part is the relative path's first part (`runs/x.png` and a known `/p/data/runs/y` give
49// `/p/data/runs/x.png`); a bare file name, beside each known path. -> candidate absolute paths, nearest first.
50export function candidates(rel: string, known: readonly string[], roots: readonly string[]): string[] {
51 const r = rel.replace(/\\/g, "/").replace(/^\.\//, "");
52 const first = r.split("/")[0] ?? "";
53 const out = new Set<string>(roots.map((root) => `${root.replace(/\/+$/, "")}/${r}`));
54 for (const k of known) {
55 if (r.includes("/")) {
56 for (let i = k.indexOf(`/${first}`); i >= 0; i = k.indexOf(`/${first}`, i + 1)) {
57 const end = i + first.length + 1;
58 if (end === k.length || k[end] === "/") out.add(`${k.slice(0, i)}/${r}`);
59 }
60 } else {
61 out.add(`${k.slice(0, k.lastIndexOf("/"))}/${r}`);
62 out.add(`${k}/${r}`);
63 }
64 }
65 return [...out].filter((c) => c.length > r.length + 1).slice(0, 60);
66}
67
68// ponytail: places whose lookup can reach another machine are never checked (automounts, shares); add more when one bites.
69const remote = (abs: string): boolean => /^\/\/|^\/(net|Network)\//.test(abs);
70
71// The command that opens it: the editor at the line when one is set, else the system's own opener (no shell).
72export type Os = "windows" | "mac" | "linux";
73export function opener(abs: string, line: number | undefined, editor: string, os: Os): string[] {
74 const native = os === "windows" ? abs.replace(/\//g, "\\") : abs;
75 if (editor) return line ? [editor, "-g", `${native}:${line}`] : [editor, native];
76 return os === "windows" ? ["explorer", native] : [os === "mac" ? "open" : "xdg-open", native];
77}
78
79const href = (abs: string): string => "file://" + (abs.startsWith("/") ? "" : "/") + encodeURI(abs).replace(/[#?()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
80
81const seen = new Map<string, { ok: boolean; at: number }>(); // abs -> exists, for a minute: a reply is drawn again on every scroll
82async function exists($: EngineInterface, abs: string): Promise<boolean> {
83 const now = await $.clock.now(), hit = seen.get(abs);
84 if (hit && now - hit.at < 60000) return hit.ok;
85 const ok = (await $.fs.stat(abs).catch(() => null)) != null;
86 seen.set(abs, { ok, at: now });
87 if (seen.size > 2000) seen.clear();
88 return ok;
89}
90
91// Paths this session's tools and replies named, newest last: where Claude has been working, outside the session folder.
92const known = new Map<string, true>();
93function learn(paths: readonly string[]): void {
94 for (const p of paths) { known.delete(p); known.set(p, true); }
95 for (const k of known.keys()) { if (known.size <= 500) break; known.delete(k); }
96}
97const strings = (v: unknown, out: string[] = []): string[] => {
98 if (typeof v === "string") out.push(v);
99 else if (Array.isArray(v)) for (const x of v) strings(x, out);
100 else if (v && typeof v === "object") for (const x of Object.values(v)) strings(x, out);
101 return out;
102};
103
104// Once per load: the tool calls already in the conversation, so a resumed session knows where its earlier work was.
105let readBack: Promise<void> | null = null;
106async function readEarlier($: EngineInterface, home: string | undefined): Promise<void> {
107 try {
108 for (const m of await $.session.messages({ as: "api" })) for (const b of m.content) if (b.type === "tool_use") learn(pathsIn(strings(b.input).join("\n"), home));
109 } catch { /* no history to read (an older engine, or none yet): only what this load sees */ }
110}
111
112async function homeOf($: EngineInterface): Promise<string | undefined> {
113 return (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
114}
115
116export const register: Register = (on, options) => {
117 const editor = String(options?.editor ?? "").trim();
118 const skipPlanning = options?.skipPlanning !== false;
119 const rootsText = String(options?.roots ?? "");
120
121 // Before each tool runs: note the paths in its arguments. The call itself is passed on unchanged.
122 on("tool.call", async ($, e, next) => {
123 const { tool: _t, tool_use_id: _id, agentId: _a, ...args } = e as unknown as Record<string, unknown>;
124 learn(pathsIn(strings(args).join("\n"), await homeOf($)));
125 return next(e);
126 });
127
128 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
129 if (e.surface !== "terminal") return next(e);
130 const refs = pathRefs(e.props?.text);
131 if (!refs.length) return next(e);
132 const cwd = await $.session.cwd();
133 const home = await homeOf($);
134 learn(pathsIn(e.props?.text ?? "", home));
135 const roots = rootsText.split(/[;\n]/).map((x) => x.trim()).filter(Boolean).map((x) => absolute(x, cwd, home) ?? x);
136 const found: (PathRef & { abs: string; href: string })[] = [];
137 for (const r of refs.slice(0, 24)) {
138 let abs = absolute(r.path, cwd, home);
139 if (abs && !/^([A-Za-z]:[\\/]|\/|~[\\/])/.test(r.path) && !remote(abs) && !(await exists($, abs))) {
140 // not in the session folder: try the roots and the folders this session worked in; link only one clear answer
141 readBack ??= readEarlier($, home);
142 await readBack;
143 const hits = new Set<string>();
144 for (const c of candidates(r.path, [...known.keys()].reverse(), roots)) if (!remote(c) && (await exists($, c))) hits.add(c);
145 abs = hits.size === 1 ? [...hits][0]! : null;
146 }
147 if (!abs || remote(abs) || found.some((f) => f.abs === abs && f.line === r.line)) continue;
148 if (skipPlanning && /(^|\/)\.planning\/.*\.md$/i.test(abs)) continue;
149 if (await exists($, abs)) found.push({ ...r, abs, href: href(abs) + (r.line ? `#L${r.line}` : "") });
150 if (found.length >= 12) break;
151 }
152 if (!found.length) return next(e);
153 // ponytail: the OS read off the paths (a drive letter, a /Users home); a $ call that names it would be better
154 const os: Os = /^[A-Za-z]:/.test(cwd) ? "windows" : String(home).startsWith("/Users/") ? "mac" : "linux";
155 const label = (t: string): string => t.replace(/([\\`*_[\]<>])/g, "\\$1");
156 const text = `⎿ open: ${found.map((f) => `[${label(f.text)}](${f.href})`).join(" · ")}`;
157 const { Box, Markdown } = $.ui.resolve(e);
158 const press = async (link: PressedLink): Promise<void> => {
159 const f = found.find((x) => x.href === link.href);
160 if (!f) return;
161 const argv = opener(f.abs, f.line, editor, os);
162 const r = await $.process.run(argv, { timeoutMs: 15000 }).catch((err) => ({ exitCode: -1, stderr: String(err) }));
163 // explorer exits 1 even when it opened the file, so only a failure to start counts there
164 if (r.exitCode !== 0 && !(os === "windows" && !editor && r.exitCode === 1)) $.ui.toast(`open-links: could not open ${f.text} (${argv[0]})`);
165 };
166 return Box({ flexDirection: "column", children: [
167 await next(e),
168 Box({ paddingLeft: 2, children: [Markdown({ key: "open-links", dimColor: true, text, pressableLinks: found.map((f) => f.href), onLinkPress: (l) => void press(l) })] }),
169 ] });
170 });
171};
172