SLOPSHOPPER

finder-links

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.

newrowstoastprocess
v0.1.0no licenseupdated 2026-10-24ydisli/finder-links
A shopper browsing a rack in a slop shop
README

finder-links

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.

  • Paths written plainly or in backticks are linked, ~/... included.
  • Only paths that exist on your Mac are linked, so an example path stays plain text.
  • Commands in code blocks, links already written and web addresses are left as they are.
  • It only shows things in Finder; it never opens or runs a file.

Install

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.

Source 2 files
hooks/register.tsx 44 lines
1import 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}
44
hooks/paths.ts 113 lines
1// 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