File and folder paths in Claude's replies become links: a click opens the folder in Finder, or shows the file selected in its folder.

A mod for Claude Code on the Mac. File and folder paths in Claude's replies become links: a click on a folder opens it in Finder, and a click on a file shows it selected in its folder.
~/... included.At the prompt of a Claude Code terminal session:
/plugin install finder-links --marketplace ydisli/finder-links
Answer y to add the marketplace, then choose the user scope so it works in every session.
From a shell:
claude plugin install finder-links --marketplace ydisli/finder-links
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ydisli/finder-links, then Add plugin.
hooks/register.tsx 44 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { findPaths, linkPaths, pathOf } from './paths.ts'
4
5// Paths in Claude's replies, made links: a click on a folder opens it in Finder, a click on a
6// file shows it selected in its folder. Only paths that exist on this Mac are linked; fenced
7// code (commands to copy) and links already written are left as they are.
8
9/** Opens a folder in Finder, or shows a file selected in its folder. */
10async function reveal($: EngineInterface, href: string): Promise<void> {
11 const path = pathOf(href)
12 if (path === undefined) return
13 const stat = await $.fs.stat(path).catch(() => undefined)
14 if (stat === undefined || stat.kind === 'other') {
15 $.ui.toast(`Not found: ${path}`)
16 return
17 }
18 const argv = stat.kind === 'dir' ? ['/usr/bin/open', path] : ['/usr/bin/open', '-R', path]
19 const { exitCode } = await $.process.run(argv).catch(() => ({ exitCode: -1 }))
20 if (exitCode !== 0) $.ui.toast(`Finder could not open ${path}`)
21}
22
23export const register: Register = on => {
24 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
25 // (A phone cannot open this Mac's Finder; a summary block is the model's text retold.)
26 if (e.props.isSummary === true || (e.surface !== 'desktop' && e.surface !== 'terminal')) return next(e)
27 const found = findPaths(e.props.text, await $.env.get('HOME'))
28 if (found.length === 0) return next(e)
29
30 // Only what is there: an example path in prose stays text.
31 const there = new Set<string>()
32 for (const path of new Set(found.map(f => f.path))) {
33 const stat = await $.fs.stat(path).catch(() => undefined)
34 if (stat !== undefined && stat.kind !== 'other') there.add(path)
35 }
36 const real = found.filter(f => there.has(f.path))
37 if (real.length === 0) return next(e)
38
39 const { text, hrefs } = linkPaths(e.props.text, real)
40 const { Markdown } = $.ui.resolve(e)
41 return <Markdown key="paths" text={text} pressableLinks={hrefs} onLinkPress={link => void reveal($, link.href)} />
42 })
43}
44hooks/paths.ts 113 lines1// Finding file and folder paths in a reply's markdown, and turning them into file: links.
2// Paths in fenced code blocks, in links and in URLs are left alone; a path written in backticks
3// becomes a link whose text keeps its backticks.
4
5/** A path found in a reply: where its text sits, the text to put there, and where it leads. */
6export type Found = {
7 start: number
8 end: number
9 /** The link's text as markdown: the path as written, its backticks kept or its marks escaped. */
10 label: string
11 /** The absolute path it leads to (`~` expanded). */
12 path: string
13}
14
15/** The roots a path must start from to count: the usual tops of a Mac's file system, and `~`. */
16const ROOTS = '(?:Users|Volumes|Applications|private|tmp|opt|Library|System|var|etc|usr)'
17
18/** A whole code span's content that is a path: spaces allowed, since the backticks bound it. */
19const WHOLE = new RegExp(`^(?:~/|/${ROOTS}(?:/|$))[^\\n\`]*$`)
20
21/** A path in prose: from `~/` or a root, through characters that cannot end a sentence's word. */
22const BARE = new RegExp(`(?<![\\w/~.\\-\\]])(?:~/|/${ROOTS}(?=/|\\b))[^\\s\`'"<>()\\[\\]{}|]*`, 'g')
23
24/** What else a line holds that is not prose: links, autolinks, code spans, bare URLs. */
25const TOKEN = /\[[^\]\n]*\]\([^)\n]*\)|<[A-Za-z][\w+.-]*:[^>\s]*>|(`+)([^\n]*?[^`\n])\1(?!`)|https?:\/\/\S+/g
26
27/** Punctuation that ends a sentence rather than a path. */
28const TRAILING = /[.,;:!?]+$/
29
30const FENCE = /^\s{0,3}(```|~~~)/
31
32/** Every path in `text` (fenced code left alone), `~` expanded against `home`. */
33export function findPaths(text: string, home: string | undefined): Found[] {
34 const found: Found[] = []
35 const expand = (written: string): string | undefined => {
36 let path = written
37 if (path.startsWith('~/')) {
38 if (home === undefined) return undefined
39 path = home.replace(/\/$/, '') + path.slice(1)
40 }
41 return path.length > 1 ? path.replace(/\/+$/, '') : path
42 }
43 const prose = (line: string, offset: number, from: number, to: number) => {
44 const part = line.slice(from, to)
45 for (const m of part.matchAll(BARE)) {
46 const written = m[0].replace(TRAILING, '')
47 const path = expand(written)
48 if (path === undefined || written.length < 2) continue
49 const start = offset + from + (m.index ?? 0)
50 found.push({ start, end: start + written.length, label: escape(written), path })
51 }
52 }
53 let offset = 0
54 let fenced = false
55 for (const line of text.split('\n')) {
56 if (FENCE.test(line)) {
57 fenced = !fenced
58 } else if (!fenced) {
59 let at = 0
60 for (const m of line.matchAll(TOKEN)) {
61 const index = m.index ?? 0
62 prose(line, offset, at, index)
63 const content = m[2]
64 if (content !== undefined && WHOLE.test(content.trim())) {
65 const path = expand(content.trim())
66 if (path !== undefined) {
67 found.push({ start: offset + index, end: offset + index + m[0].length, label: m[0], path })
68 }
69 }
70 at = index + m[0].length
71 }
72 prose(line, offset, at, line.length)
73 }
74 offset += line.length + 1
75 }
76 return found
77}
78
79/** A path's file: URL, each segment percent-encoded (so a space, `#` or `?` stays the path's). */
80export function fileHref(path: string): string {
81 return 'file://' + path.split('/').map(encodeURIComponent).join('/')
82}
83
84/** The path a file: URL this mod wrote leads to; undefined for any other link. */
85export function pathOf(href: string): string | undefined {
86 if (!href.startsWith('file:///')) return undefined
87 try {
88 return href.slice('file://'.length).split('/').map(decodeURIComponent).join('/')
89 } catch {
90 return undefined
91 }
92}
93
94/** `text` with each of `found` (in order, not overlapping) replaced by its link, and the links' targets. */
95export function linkPaths(text: string, found: readonly Found[]): { text: string; hrefs: string[] } {
96 let out = ''
97 let at = 0
98 const hrefs: string[] = []
99 for (const f of [...found].sort((a, b) => a.start - b.start)) {
100 if (f.start < at) continue
101 const href = fileHref(f.path)
102 out += text.slice(at, f.start) + `[${f.label}](${href})`
103 if (!hrefs.includes(href) && href.length <= 2048 && hrefs.length < 256) hrefs.push(href)
104 at = f.end
105 }
106 return { text: out + text.slice(at), hrefs }
107}
108
109/** Markdown's marks in a bare path escaped, so `a_b_c` stays itself inside a link's text. */
110function escape(written: string): string {
111 return written.replace(/[\\`*_[\]<>]/g, c => '\\' + c)
112}
113