SLOPSHOPPER

open-links

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…

newrowsguardtoastprocess
★ 1v0.2.0MITupdated 2026-10-13helenkwok/open-links
A shopper browsing a rack in a slop shop
README

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

👀 Before and after

The same reply, in the same folder, captured from two live sessions:

📥 Install

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

✨ Features

  • Every kind of path: absolute (/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).
  • Paths outside the session folder: a relative path Claude wrote while working elsewhere (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.
  • Line numbers: :42 and :42:7 stay in the label; with an editor set, a click opens it at that line.
  • Only real files: a path that does not exist gets no link, so there are no dead links. Web links, version numbers and paths that climb out with .. are skipped.
  • Your opener or your editor: the default app (open on macOS, explorer on Windows, xdg-open on Linux), or any editor that takes -g file:line (code, cursor, windsurf).
  • Plays well with gsd-status-mod: markdown under .planning is left to its reader links.

⚙️ Settings

OptionWhat it doesDefault
editorCommand to open files at the line (code, cursor, windsurf: anything that takes -g file:line). Empty: the default app.""
skipPlanningSkip markdown under .planning, which gsd-status-mod already links.true
rootsExtra folders to resolve relative paths against, separated by ; (~ allowed). The folders this session's tools worked in are learned without it.""

🧠 How it works

  1. Find: a ui.render hook on each reply (AssistantMessage) picks out the words that look like paths, with their :line.
  2. Check: each one is resolved against the session folder (or home, or taken as absolute) and checked with $.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.
  3. Draw: the engine draws the reply as usual; one dim line under it, ⎿ open: …, carries a file:// link for each path that exists.
  4. Open: clicks reach a mod in Claude Code's fullscreen terminal. A click runs the opener or your editor by argument list, with no shell. Elsewhere the link is a plain file:// hyperlink your terminal may open itself.

🔒 What it can touch

claude plugin validate .claude-plugin/plugin.json lists it:

  • Hooks: ui.render for AssistantMessage, and tool.call, which only notes the paths in a call's arguments and passes the call on unchanged.
  • Calls: $.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.
  • No network, no writes, no file contents read.

❓ FAQ

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.

🧪 Development

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.

📝 Changelog and license

Release notes: CHANGELOG.md. MIT, see LICENSE. Not affiliated with Anthropic.

Source 1 files
hooks/open-links.ts 172 lines
1import 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