SLOPSHOPPER

link

Click a file path in a reply and Finder opens with it selected

newrowstoastprocess
★ 1v0.2.2Apache-2.0updated 2026-10-03alex2481kobe/claude-mods/plugins/link
A shopper browsing a rack in a slop shop
README

link

Click a file path in a reply and Finder opens with it selected.

Claude Code prints file paths as plain text. With this mod, a path in a reply that exists on disk becomes a link: pressing it shows a file selected in its folder in Finder, or opens a folder as itself, and a toast says which.

Requirements

  • Claude Code with mods, in the terminal (the reply is only changed there)
  • macOS: it runs open and open -R
  • A terminal with hyperlinks (OSC 8), such as Ghostty or iTerm2. Apple Terminal has none: there Claude Code writes each link's file:// address after it, so a path shows twice, and pressing it does not always open it

Install

/plugin marketplace add alex2481kobe/claude-mods
/plugin install link@claude-mods

How it works

  • A ui.render hook on assistant messages finds path-like text, resolves it against the session's folder and home, and keeps only paths that exist.
  • Those paths are turned into file:// links in the reply's Markdown. A folder's URL ends in a slash, which is how a press tells it from a file.
  • Pressing a link runs open -R <file> or open <folder> and toasts the result, or the error if it failed.
  • A reply longer than 10000 characters, or with no existing path, keeps the engine's own drawing.
Source 2 files
hooks/register.tsx 51 lines
1import type { Register } from 'claude-code'
2
3import { absolute, candidates, hrefOf, linkify, targetOf } from './paths'
4
5// The most a Markdown element draws; a longer reply keeps the engine's drawing.
6const MARKDOWN_LIMIT = 10000
7// The mark the engine draws before a reply on macOS, the one platform `open -R` has.
8const BULLET = '\u23fa '
9
10export const register: Register = on => {
11  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
12    if (e.surface !== 'terminal') return next(e)
13    const found = candidates(e.props.text)
14    if (found.length === 0) return next(e)
15
16    const cwd = await $.session.cwd()
17    const home = (await $.env.get('HOME')) ?? ''
18    const hrefs = new Map<string, string>()
19    for (const path of found) {
20      if (path.startsWith('~') && home === '') continue
21      const full = absolute(path, cwd, home)
22      const stat = await $.fs.stat(full).catch(() => undefined)
23      if (stat) hrefs.set(path, hrefOf(full, stat.kind === 'dir'))
24    }
25    const text = linkify(e.props.text, hrefs)
26    if (hrefs.size === 0 || text.length > MARKDOWN_LIMIT) return next(e)
27
28    // The engine's own frame of a reply block: a blank row above, the bullet
29    // opening a reply or its width in spaces, then the markdown.
30    const { Box, Markdown, Text } = $.ui.resolve(e)
31    return (
32      <Box flexDirection="row" marginTop={1}>
33        <Text color="text">{e.props.isFirstOfReply ? BULLET : '  '}</Text>
34        <Box flexGrow={1}>
35          <Markdown
36            key="link"
37            text={text}
38            pressableLinks={[...hrefs.values()]}
39            onLinkPress={async link => {
40              const target = targetOf(link.href)
41              if (target === undefined) return
42              const { exitCode, stderr } = await $.process.run(target.argv)
43              $.ui.toast(exitCode === 0 ? target.done : `${target.failed}: ${stderr.trim() || `exit ${exitCode}`}`)
44            }}
45          />
46        </Box>
47      </Box>
48    )
49  })
50}
51
hooks/paths.ts 143 lines
1// Finds the file paths a reply's markdown writes and turns them into file:
2// links. Pure: the caller decides which paths exist and passes their hrefs in.
3
4// One name in a path: letters, digits and `_ . @ + -`, at most one leading dot
5// (`.claude`, `.env`) and never ending in one, so the full stop after a path
6// is not part of it. A folder step may also be `.` or `..`.
7const SEG = String.raw`\.?[\w@+-](?:[\w.@+-]*[\w@+-])?`
8const DIR = String.raw`(?:${SEG}|\.{1,2})`
9// Rooted (`/a`, `~/a`, `./a`, `../a`), relative with a slash (`src/a.js`,
10// `src/`), a bare file name with an extension (`app.js`), or a dotfile (`.env`).
11const PATH = [
12  String.raw`(?:~|\.{1,2})?\/(?:${DIR}\/)*${SEG}\/?`,
13  String.raw`${SEG}(?:\/${DIR})*\/(?:${SEG})?`,
14  String.raw`[\w@+-]+(?:\.[\w@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}`,
15  String.raw`\.[A-Za-z][\w@+-]*`,
16].join('|')
17const LINE = String.raw`(?::\d+(?::\d+)?)?`
18
19// Bounded so a path inside a longer word, URL or path is not taken.
20const IN_TEXT = new RegExp(String.raw`(?<![\w/.~@+:-])(${PATH})${LINE}(?![\w/@+-])`, 'g')
21const WHOLE = new RegExp(String.raw`^(${PATH})${LINE}$`)
22
23// Spans never rewritten inside: code fences, links, autolinks, bare URLs, and
24// inline code, which is linked whole when it is exactly one path.
25const SPANS = /(```|~~~)[\s\S]*?(?:\1|$)|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z]+:[^>\n]*>|[a-z][a-z0-9+.-]*:\/\/[^\s)]+|`[^`\n]+`/g
26
27type Part = { text: string; isFree: boolean }
28
29const split = (text: string): Part[] => {
30  const parts: Part[] = []
31  let at = 0
32  for (const m of text.matchAll(SPANS)) {
33    if (m.index > at) parts.push({ text: text.slice(at, m.index), isFree: true })
34    parts.push({ text: m[0], isFree: false })
35    at = m.index + m[0].length
36  }
37  if (at < text.length) parts.push({ text: text.slice(at), isFree: true })
38  return parts
39}
40
41const codePath = (span: string): string | undefined =>
42  span.startsWith('`') && !span.startsWith('``') ? WHOLE.exec(span.slice(1, -1))?.[1] : undefined
43
44const LINK = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/
45const SCHEME = /^[a-z][a-z0-9+.-]*:/i
46
47const decoded = (text: string): string | undefined => {
48  try {
49    return decodeURIComponent(text)
50  } catch {
51    return undefined
52  }
53}
54
55// What a link the reply wrote itself points at, when that is a path: a
56// `file:` URL, or a target with no scheme (`/a/b.md`, `src/a.ts#L3`).
57const linkPath = (span: string): string | undefined => {
58  const target = (LINK.exec(span)?.[2] ?? '').trim().replace(/[#?].*$/, '')
59  if (target.startsWith('file:///')) return decoded(target.slice('file://'.length))
60  if (target === '' || SCHEME.test(target)) return undefined
61  return decoded(target.replace(/:\d+(?::\d+)?$/, ''))
62}
63
64// Each distinct path the text writes, as written less its `:line`, in order:
65// in its prose, as inline code, or as the target of a link it wrote.
66export const candidates = (text: string): string[] => {
67  const found = new Set<string>()
68  for (const part of split(text)) {
69    if (part.isFree) {
70      for (const m of part.text.matchAll(IN_TEXT)) found.add(m[1] ?? '')
71    } else {
72      found.add(codePath(part.text) ?? linkPath(part.text) ?? '')
73    }
74  }
75  found.delete('')
76  return [...found]
77}
78
79// Past this many characters a label shows only its last two names.
80const LONG_LABEL = 32
81
82// What a link to `path` (as written, `line` after it) shows: a file as inline
83// code, a folder as plain text ending in a slash, a long one cut to `.../a/b`.
84const labelOf = (path: string, line: string, isFolder: boolean): string => {
85  const bare = path.replace(/\/$/, '')
86  const names = bare.split('/')
87  const isLong = bare.length + line.length > LONG_LABEL && names.length > 2
88  const shown = isLong ? `\u2026/${names.slice(-2).join('/')}` : bare
89  return isFolder ? `${shown.replace(/[\\_~*]/g, '\\$&')}/` : `\`${shown}${line}\``
90}
91
92const linkOf = (path: string, line: string, href: string) => `[${labelOf(path, line, href.endsWith('/'))}](${href})`
93
94// The text with each path `hrefs` holds made a link to its href; a link the
95// reply wrote keeps its label and gets the href.
96export const linkify = (text: string, hrefs: ReadonlyMap<string, string>): string =>
97  split(text)
98    .map(part => {
99      if (part.isFree) {
100        return part.text.replace(IN_TEXT, (all, path: string) => {
101          const href = hrefs.get(path)
102          return href === undefined ? all : linkOf(path, all.slice(path.length), href)
103        })
104      }
105      const code = codePath(part.text)
106      const href = hrefs.get(code ?? linkPath(part.text) ?? '')
107      if (href === undefined) return part.text
108      if (code !== undefined) return linkOf(code, part.text.slice(1 + code.length, -1), href)
109      return `[${LINK.exec(part.text)?.[1] ?? ''}](${href})`
110    })
111    .join('')
112
113// `~` as `home`, a relative path under `cwd`, `.` and `..` folded away.
114export const absolute = (path: string, cwd: string, home: string): string => {
115  const full = path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}`
116  const out: string[] = []
117  for (const seg of full.split('/')) {
118    if (seg === '..') out.pop()
119    else if (seg !== '' && seg !== '.') out.push(seg)
120  }
121  return `/${out.join('/')}`
122}
123
124// Every name escaped, parentheses too, so the href cannot end the markdown link.
125const escape = (seg: string) => encodeURIComponent(seg).replace(/[()!*']/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
126
127// A folder's URL ends in a slash, as file URLs write one, which is how a press
128// tells a folder from a file.
129export const hrefOf = (path: string, isFolder: boolean): string =>
130  `file://${path.split('/').map(escape).join('/')}${isFolder && path !== '/' ? '/' : ''}`
131
132export type Target = { argv: string[]; done: string; failed: string }
133
134// What a press on `href` runs, Finder showing a file selected in its folder or
135// a folder opened as itself, and what to say after; nothing for another link.
136export const targetOf = (href: string): Target | undefined => {
137  if (!href.startsWith('file:///')) return undefined
138  const path = decodeURIComponent(href.slice('file://'.length))
139  return href.endsWith('/')
140    ? { argv: ['open', path], done: `Opened ${path} in Finder`, failed: `Could not open ${path}` }
141    : { argv: ['open', '-R', path], done: `Revealed ${path} in Finder`, failed: `Could not reveal ${path}` }
142}
143