SLOPSHOPPER

file-links

Turns file mentions in Claude's replies into clickable links to where the file really is / Превращает упоминания файлов в ответах Claude в кликабельные ссылки…

newrows
★ 1v1.0.0MITupdated 2026-10-24ArtyaEXE/claude-mods/file-links
A shopper browsing a rack in a slop shop
README

file-links

Turns file mentions in Claude's replies into clickable links to where the file really is.

English · Русский

English

Claude often names a file as src/app.ts or C:\Users\me\notes.md, and the mention is not clickable, or opens nothing because the path is relative to another folder. This mod finds path-shaped mentions (in inline code or bare absolute Windows paths), looks the file up from the session's folder and project root, and turns each one it finds into a file:/// link. A :12 line suffix is kept. Code blocks, existing links and files that do not exist are left as written.

What it touches

Checks whether mentioned paths exist (exists / stat) and changes only how a reply is drawn; what the model reads is not changed. Reads USERPROFILE to resolve ~.

Settings (/config)

No settings.

Install

/plugin install file-links --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

Превращает упоминания файлов в ответах Claude в кликабельные ссылки на то место, где файл реально лежит.

Claude часто называет файл как src/app.ts или C:\Users\me\notes.md, и упоминание либо не кликается, либо открывает пустоту, потому что путь относительный. Мод находит упоминания, похожие на пути (в inline code или голые абсолютные Windows-пути), ищет файл от папки сессии и корня проекта и превращает каждое найденное в ссылку file:///. Суффикс строки :12 сохраняется. Блоки кода, готовые ссылки и несуществующие файлы не трогает.

Что мод делает в системе

Проверяет, существуют ли упомянутые пути (exists / stat), и меняет только отрисовку ответа; то, что читает модель, не меняется. Читает USERPROFILE, чтобы раскрыть ~.

Настройки (/config)

Настроек нет.

Установка

/plugin install file-links --marketplace ArtyaEXE/claude-mods

Часть claude-mods. Лицензия MIT.

Source 1 files
hooks/register.ts 123 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// A mention is either `inline code` that looks like a path, or a bare
4// absolute Windows path. A `:12` or `:12:3` line suffix is kept outside the link.
5const CODE_MENTION = /`([^`\n]{2,260})`/g
6const BARE_ABSOLUTE = /(?<![\w/\\`(\[])([A-Za-z]:[\\/][^\s`<>"'()\[\]*|?]+)/g
7const LINE_SUFFIX = /:(\d+)(?::\d+)?$/
8
9// Path-shaped: has a separator or a file extension, and nothing a command has.
10const looksLikePath = (s: string) =>
11  !/\s{2}|[<>|"*?]|^\w+\(|=/.test(s) && (/[\\/]/.test(s) || /\.[A-Za-z0-9]{1,8}$/.test(s)) && !/^https?:/i.test(s)
12
13const isAbsolute = (s: string) => /^[A-Za-z]:[\\/]/.test(s) || s.startsWith('/') || s.startsWith('~')
14
15const fileUrl = (abs: string) =>
16  'file:///' +
17  abs
18    .replace(/\\/g, '/')
19    .split('/')
20    .map((part, i) => (i === 0 && /^[A-Za-z]:$/.test(part) ? part : encodeURIComponent(part)))
21    .join('/')
22
23const MAX_CACHE = 2000
24const found = new Map<string, string | null>()
25
26const resolve = async ($: EngineInterface, mention: string, home: string, bases: string[]): Promise<string | null> => {
27  const key = `${bases.join('|')}::${mention}`
28  if (found.has(key)) return found.get(key) ?? null
29
30  const raw = mention.replace(/^~(?=[\\/])/, home)
31  const candidates = isAbsolute(raw) && !raw.startsWith('~') ? [raw] : bases.map(b => `${b}/${raw.replace(/^\.[\\/]/, '')}`)
32  let hit: string | null = null
33  for (const candidate of candidates) {
34    try {
35      if (await $.fs.exists(candidate)) {
36        const stat = await $.fs.stat(candidate, { resolve: true })
37        hit = (stat.realPath ?? candidate).replace(/\//g, '\\')
38        break
39      }
40    } catch {
41      // Unreadable spot: try the next base.
42    }
43  }
44
45  if (found.size >= MAX_CACHE) found.clear()
46  found.set(key, hit)
47
48  return hit
49}
50
51// Fenced code blocks and existing markdown links stay as written.
52const SKIP = /```[\s\S]*?(?:```|$)|\[[^\]\n]*\]\([^)\n]*\)/g
53
54const linkify = async ($: EngineInterface, text: string): Promise<string> => {
55  if (!/`|[A-Za-z]:[\\/]/.test(text)) return text
56
57  const cwd = (await $.session.cwd()).replace(/\\/g, '/')
58  const root = (await $.session.root()).replace(/\\/g, '/')
59  const home = String((await $.env.get('USERPROFILE')) ?? '').replace(/\\/g, '/')
60  const bases = [...new Set([cwd, root])]
61
62  const linkOne = async (shown: string, mention: string) => {
63    const bare = mention.replace(LINE_SUFFIX, '')
64    if (!looksLikePath(bare)) return null
65    const abs = await resolve($, bare, home, bases)
66
67    return abs === null ? null : `[${shown}](${fileUrl(abs)})`
68  }
69
70  const pieces: string[] = []
71  let at = 0
72  for (const skip of text.matchAll(SKIP)) {
73    pieces.push(await linkPlain(text.slice(at, skip.index), linkOne))
74    pieces.push(skip[0])
75    at = skip.index + skip[0].length
76  }
77  pieces.push(await linkPlain(text.slice(at), linkOne))
78
79  return pieces.join('')
80}
81
82const linkPlain = async (
83  part: string,
84  linkOne: (shown: string, mention: string) => Promise<string | null>,
85): Promise<string> => {
86  let out = ''
87  let at = 0
88  // Inline code first; bare paths only in the text between code spans.
89  for (const m of part.matchAll(CODE_MENTION)) {
90    out += await linkBare(part.slice(at, m.index), linkOne)
91    out += (await linkOne(m[0], m[1] ?? "")) ?? m[0]
92    at = m.index + m[0].length
93  }
94
95  return out + (await linkBare(part.slice(at), linkOne))
96}
97
98const linkBare = async (
99  part: string,
100  linkOne: (shown: string, mention: string) => Promise<string | null>,
101): Promise<string> => {
102  let out = ''
103  let at = 0
104  for (const m of part.matchAll(BARE_ABSOLUTE)) {
105    // A sentence's closing punctuation is not part of the path.
106    const mention = (m[1] ?? "").replace(/[.,;:!]+$/, '')
107    out += part.slice(at, m.index)
108    out += (await linkOne(mention, mention)) ?? mention
109    at = m.index + mention.length
110  }
111
112  return out + part.slice(at)
113}
114
115export const register: Register = on => {
116  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
117    const text = await linkify($, e.props.text)
118    if (text === e.props.text) return next(e)
119
120    return next({ ...e, props: { ...e.props, text } })
121  })
122}
123