Click a file path in a Claude Code reply and the file opens in its app: Word, Preview, Excel, VS Code for code. Not a Finder reveal.

Click a file path in Claude Code, and the file opens in its app.
Claude writes ~/Desktop/Report.docx. You Cmd+click it. Word opens. A PDF opens in Preview, a spreadsheet in Excel, app.py:42 in VS Code at line 42.
Out of the box, Claude Code's fullscreen renderer answers a click on a file path by showing the file in your file manager, not opening it (fullscreen docs; see also #71658), and paths in Claude's replies are often not links at all. open-paths turns every path Claude writes into a link that opens the file itself.

At the prompt of a Claude Code session:
/plugin install open-paths --marketplace kiankhooban/open-paths
Answer y to add the marketplace, pick a scope, and it is active at once. From a shell:
claude plugin install open-paths --marketplace kiankhooban/open-paths
| You click | It opens in |
|---|---|
.docx .pdf .xlsx .pptx .png .csv .html and every other document | the file type's default app |
.py .java .ts .c .go .rs .json .md and 100+ other code and text types | your editor (VS Code by default), at the line when one is written: app.py:42, main.c:10:5 |
| a folder | Finder, Explorer or your file manager |
.sh .command .ps1 and other scripts | your editor, never their default app: opened by a terminal, a script runs |
.app .pkg .dmg .exe, and any type not on the lists above | revealed in the file manager, never launched |
~/ paths and paths relative to Claude's working directory. ~/My Drive/Notes Q1.docx `.file:line and file:line:col.↳ open row under the block, so the code stays copyable.~/output/Report.docx, later says "see Report.docx", and that name opens the right file.Write tool rows get an ⎿ open line under them (/open-paths tools all adds Read and Edit).Only paths that exist become links, checked against the file system with a short cache, so a made-up or stale path stays plain text.
Run /open-paths to see the current settings.
/open-paths editor cursor vscode, cursor, zed, sublime, idea, default, or any app name
/open-paths set pdf Skim send a file type to an app
/open-paths set txt editor ... or to the editor, the default app, or a reveal
/open-paths unset pdf back to the built-in rule
/open-paths folders editor folders open in the editor instead of the file manager
/open-paths tools all|write|off open lines under tool rows
/open-paths toast off no "Opening Report.docx" toast
/open-paths mode inline|auto|below|off how links are drawn (below)
A script or launcher type cannot be sent to its default app or another app, only to the editor or a reveal.
Mods that redraw Claude's replies (themes, markdown viewers, math renderers) and open-paths both want to draw the same reply. The default inline mode draws replies that contain paths itself, so the links sit in the text. auto keeps another mod's drawing whole and adds a dim ↳ open row of links under it; below always uses the row.
Supports macOS (open), Linux (xdg-open), Windows (start, Explorer) and WSL (wslview, or Explorer with the path translated by wslpath).
In Claude Code's fullscreen renderer the mod answers the click itself. In the classic renderer the terminal follows the link: documents go as file:// links, code goes through your editor's URL scheme (vscode://file/...:42), and launchers and scripts get no terminal link at all.
Claude's replies can be steered by text it reads (a web page, a file), so open-paths treats every path in a reply as untrusted:
report.pdf that leads to an app is revealed, not opened.cmd.exe would act on (& | ^ % !) is refused, not passed on.Two limits worth knowing: .html and .svg open in your browser, which runs their scripts exactly as a double-click would; and a file swapped for another in the instant between the click and the open is opened as whatever it now is, as with any opener.
Sends: nothing. open-paths makes no network calls. Nothing leaves your machine; the only thing that ever leaves the mod is a file path, handed as an argument to a local program when you click.
Hooks, all in hooks/register.tsx:
| Hook | What it does |
|---|---|
ui.render on AssistantMessage | Finds paths in a reply, checks they exist, and draws the reply with those paths as links the mod answers. The stored conversation is not changed. |
ui.render on ToolUse | Adds an ⎿ open line under Write (or Read/Write/Edit) tool rows. |
tool.call | After a Read, Write, Edit or NotebookEdit call, remembers the file's path so a bare name in a later reply finds it. It never blocks, changes or reruns a tool call. |
session.start | Loads settings and registers the /open-paths command. |
command.run for /open-paths | Shows or changes the settings. |
Reads: file metadata only ($.fs.stat: does the path exist, is it a file or folder, where does a symlink lead); the session's working directory; and the mod's own install folder, to find your home folder for ~ paths. It reads no environment variables, credentials, tokens or file contents.
Stores (in the mod's own $.store, on your machine): your /open-paths settings, and the list of file paths Claude's tools touched, capped at 500 names.
Runs, by argument vector with no shell, and only these programs:
| Program | When | Why |
|---|---|---|
uname -sr | once per session, on the first click | to tell macOS, Linux and WSL apart |
open <file>, open -a <app> <file>, open -R <file>, open -t <file> | on a click, macOS | open in the default app, a chosen app, reveal in Finder, or the plain text editor |
xdg-open <file or folder> | on a click, Linux | open in the default app, or show the folder |
explorer.exe <file>, explorer.exe /select,<file> | on a click, Windows and WSL | open in the default app, or reveal |
wslview <file>, wslpath -w <file> | on a click, WSL | open through Windows, and translate the path for it |
code -g, cursor -g, zed, subl, idea --line | on a click on code, if that editor is chosen | open the editor at the line |
cmd /c <editor command> <file> | on a click on code, Windows | editor launchers there are .cmd files; a name cmd.exe would act on is refused first |
<file> is always the real path of a file that exists, after the Safety rules above. <app> is an app you named yourself with /open-paths set or /open-paths editor.
A ui.render hook on assistant messages finds the paths, resolves them against the session's working directory and home, checks they exist, and draws the reply as markdown whose links the mod answers (onLinkPress). A click runs one command, by argument vector with no shell: open, xdg-open, cmd /c start, or your editor's command line. Nothing leaves your machine, and the stored conversation is never changed: only its drawing carries the links.
git clone https://github.com/kiankhooban/open-paths
claude --plugin-dir ./open-paths # a session with the mod loaded, reloading as you edit
claude plugin validate ./open-paths
claude plugin test ./open-paths # the tests, run by Claude Code's own engine
MIT
hooks/register.tsx 311 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { actionFor, describe, terminalHref } from './openers'
4import type { Action, Kind, Platform } from './openers'
5import { applyEdits, blockRows, findBlocks, findRefs, homeOf, hrefOf, isWindowsPath, linkOf, linkRow, resolvePath, shortLabel } from './paths'
6import type { Ctx, Item, Ref } from './paths'
7import { command, DEFAULTS, prefsOf } from './prefs'
8import type { Prefs } from './prefs'
9import { recall, remember } from './touched'
10import type { Touched } from './touched'
11
12/** How long a lookup is trusted: a file that exists rarely vanishes, a missing one may appear soon. */
13const HIT_MS = 60_000
14const MISS_MS = 5_000
15/** Lookups one drawing may start; the rest wait for the next drawing. */
16const MAX_PROBES = 64
17const MAX_CACHE = 2000
18/** A stat that has not answered by then counts as missing, so a stalled mount never holds up a reply. */
19const STAT_TIMEOUT_MS = 300
20/** Tool calls whose file is remembered, and which tool rows get an open line under each setting. */
21const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
22const ROW_TOOLS = { write: new Set(['Write']), all: FILE_TOOLS, off: new Set<string>() }
23
24/** What a path is and where it really lands, every symlink followed; null when missing or refused. */
25type Found = { kind: Kind; real: string }
26const cache = new Map<string, { found: Found | null; at: number }>()
27let prefs: Prefs = DEFAULTS
28let touched: Touched = {}
29let home: string | undefined
30let platform: Platform | undefined
31/** The touched index is saved one write at a time, so an older snapshot never lands after a newer one. */
32let saving: Promise<unknown> = Promise.resolve()
33
34/** Opening a network path can hand credentials to the host it names, whatever spelling led there. */
35const isNetwork = (real: string) => /^(\/\/|\\\\)|^\/(net|Network)(\/|$)/.test(real)
36
37async function statOf($: EngineInterface, abs: string): Promise<Found | null> {
38 let timer: { cancel(): void } | undefined
39 const late = new Promise<undefined>(done => {
40 timer = $.clock.after(STAT_TIMEOUT_MS, () => done(undefined))
41 })
42 const st = await Promise.race([$.fs.stat(abs, { resolve: true }).catch(() => undefined), late])
43 timer?.cancel()
44 const kind = st?.kind === 'file' ? 'file' : st?.kind === 'dir' ? 'dir' : null
45 const real = st?.realPath?.replace(/\\/g, '/')
46 return kind && real && !isNetwork(real) ? { kind, real } : null
47}
48
49async function lookUp($: EngineInterface, paths: string[]): Promise<void> {
50 const now = await $.clock.now()
51 const stale = paths.filter(p => {
52 const c = cache.get(p)
53 return !c || now - c.at > (c.found ? HIT_MS : MISS_MS)
54 })
55 await Promise.all(
56 stale.slice(0, MAX_PROBES).map(async p => {
57 const found = await statOf($, p)
58 cache.delete(p) // re-inserted last, so eviction takes the oldest lookups
59 cache.set(p, { found, at: now })
60 }),
61 )
62 if (cache.size > MAX_CACHE) for (const k of [...cache.keys()].slice(0, cache.size - MAX_CACHE)) cache.delete(k)
63}
64
65// The home folder comes from where the mod is installed, not from any variable.
66async function ctxOf($: EngineInterface): Promise<Ctx> {
67 const cwd = await $.session.cwd().catch(() => undefined)
68 if (home === undefined) home = homeOf($.plugin.root, cwd) ?? ''
69 return { home: home || undefined, cwd }
70}
71
72/**
73 * The system the session runs on, or undefined when it cannot be told: then nothing is opened, since a
74 * guess is unsafe (`open` on Debian is openvt, which runs the file). A failed check is tried again.
75 */
76async function platformOf($: EngineInterface): Promise<Platform | undefined> {
77 if (platform) return platform
78 if (isWindowsPath($.plugin.root) || isWindowsPath(await $.session.cwd().catch(() => undefined))) return (platform = 'windows')
79 const r = await $.process.run(['uname', '-sr'], { timeoutMs: 3000 }).catch(() => undefined)
80 const s = r?.exitCode === 0 ? r.stdout : ''
81 if (/^Darwin/.test(s)) return (platform = 'mac')
82 if (/^Linux/.test(s)) return (platform = /microsoft/i.test(s) ? 'wsl' : 'linux')
83 return undefined
84}
85
86/** Each written path's real path and kind: as written first, else the newest touched file it may mean. */
87async function resolveAll($: EngineInterface, refs: Ref[]): Promise<Map<Ref, { abs: string; kind: Kind }>> {
88 const ctx = await ctxOf($)
89 const tries = refs.map(ref => {
90 const first = resolvePath(ref.path, ctx)
91 return { ref, cands: [...(first ? [first] : []), ...recall(touched, ref.path)] }
92 })
93 await lookUp($, [...new Set(tries.flatMap(t => t.cands))])
94 const out = new Map<Ref, { abs: string; kind: Kind }>()
95 for (const { ref, cands } of tries) {
96 const hit = cands.map(c => cache.get(c)?.found).find(Boolean)
97 if (hit) out.set(ref, { abs: hit.real, kind: hit.kind })
98 }
99 return out
100}
101
102/** The file and line a link of ours points at. */
103export function targetOf(href: string): { abs: string; line?: number; col?: number } | undefined {
104 const m = /^file:\/\/([^#]*)(?:#L(\d+)(?::(\d+))?)?$/.exec(href)
105 if (!m) return undefined
106 try {
107 const abs = decodeURIComponent(m[1]!).replace(/^\/([A-Za-z]:\/)/, '$1')
108 return { abs, line: m[2] ? Number(m[2]) : undefined, col: m[3] ? Number(m[3]) : undefined }
109 } catch {
110 return undefined
111 }
112}
113
114async function run($: EngineInterface, action: Action, abs: string): Promise<boolean> {
115 if (action.how === 'refuse') return false
116 let argv = action.argv
117 let fallback = action.fallback
118 if (action.windowsPath) {
119 const w = await $.process.run(['wslpath', '-w', abs], { timeoutMs: 3000 }).catch(() => undefined)
120 const win = w?.exitCode === 0 ? w.stdout.trim() : undefined
121 if (!win) {
122 $.ui.toast('open-paths: could not translate the path for Windows')
123 return false
124 }
125 // A function replacer: a `$&` or `$1` in a Windows path is text, not a pattern.
126 argv = argv.map(a => a.replace(abs, () => win))
127 fallback = fallback?.map(a => a.replace(abs, () => win))
128 }
129 const tryRun = (cmd: string[]) =>
130 $.process.run(cmd, { timeoutMs: 10_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
131 let res = await tryRun(argv)
132 if (res.exitCode !== 0 && fallback) res = await tryRun(fallback)
133 if (res.exitCode !== 0) $.ui.toast(`open-paths: could not open it (${res.stderr.trim().slice(0, 120) || `exit ${res.exitCode}`})`)
134 return res.exitCode === 0
135}
136
137/** A click on one of our links: the file opens in its app, never a Finder reveal (unless it is a launcher). */
138export async function openHref($: EngineInterface, href: string): Promise<void> {
139 const t = targetOf(href)
140 if (!t) return
141 const found = await statOf($, t.abs)
142 if (!found) {
143 $.ui.toast(`open-paths: could not find ${t.abs}`)
144 return
145 }
146 const plat = await platformOf($)
147 if (!plat) {
148 $.ui.toast('open-paths: could not tell which system this is, so nothing was opened')
149 return
150 }
151 // Classified and opened by where it really lands: a link named report.pdf may lead to an app.
152 const action = actionFor(found.real, found.kind, prefs, plat, t.line, t.col)
153 if (prefs.toast || action.how === 'refuse') $.ui.toast(describe(action, found.real, t.line))
154 await run($, action, found.real)
155}
156
157const itemOf = (ref: Ref, abs: string): Item => ({ ref, href: hrefOf(abs, ref.line, ref.col) })
158
159export const register: Register = (on, options) => {
160 on('session.start', async ($, e, next) => {
161 prefs = prefsOf(options, await $.store.get('prefs').catch(() => undefined))
162 const t = await $.store.get('touched').catch(() => undefined)
163 touched = t && typeof t === 'object' ? Object.assign(Object.create(null) as Touched, t) : Object.create(null)
164 await $.command.register({
165 name: 'open-paths',
166 description: 'open-paths: click a path, the file opens in its app (/open-paths help)',
167 argumentHint: '[setting] [value] (/open-paths help lists them)',
168 immediate: true,
169 })
170 return next(e)
171 }).catch(($, e, next) => next(e))
172
173 on('command.run', { command: 'open-paths' }, async ($, e) => {
174 const res = command(prefs, e.args)
175 if (res.prefs) {
176 prefs = res.prefs
177 await $.store.set('prefs', prefs).catch(() => undefined)
178 $.ui.invalidate('ui.render')
179 }
180 return { text: res.text }
181 })
182
183 // Remember the files Claude's tools touch, so a bare name in a later reply finds them.
184 on('tool.call', async ($, e, next) => {
185 const done = await next(e)
186 const input = e as { tool?: unknown; file_path?: unknown; notebook_path?: unknown }
187 const file = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
188 const abs = typeof file === 'string' ? resolvePath(file, await ctxOf($)) : undefined
189 if (FILE_TOOLS.has(String(input.tool)) && abs && done.deny === undefined && !done.isError) {
190 touched = remember(touched, abs)
191 const snapshot = touched
192 saving = saving.then(() => $.store.set('touched', snapshot)).catch(() => undefined)
193 // A reply drawn before the file existed cached a miss: forget it and draw again.
194 if (cache.get(abs)?.found === null) {
195 cache.delete(abs)
196 $.ui.invalidate('ui.render')
197 }
198 }
199 return done
200 }).catch(($, e, next) => next(e))
201
202 // The stored reply stays as written; only its drawing carries the links.
203 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
204 if (prefs.mode === 'off' || e.surface !== 'terminal' || e.props.isSummary) return next(e)
205 const text = e.props.text
206 const refs = findRefs(text)
207 const blocks = findBlocks(text)
208 if (refs.length === 0 && blocks.length === 0) return next(e)
209
210 const found = await resolveAll($, [...refs, ...blocks.flatMap(b => b.refs)])
211 const items = refs.flatMap(r => (found.has(r) ? [itemOf(r, found.get(r)!.abs)] : []))
212 const blockItems = blocks.map(b => ({ end: b.end, items: b.refs.flatMap(r => (found.has(r) ? [itemOf(r, found.get(r)!.abs)] : [])) }))
213 const all = [...items, ...blockItems.flatMap(b => b.items)]
214 if (all.length === 0) return next(e)
215
216 const { Box, Text, Markdown, Link } = $.ui.resolve(e)
217 const drawn = await next(e)
218
219 // The classic renderer reports no clicks: the terminal follows the link itself.
220 if (e.viewport?.isFullscreen === false) {
221 const plat = await platformOf($)
222 const links = all.flatMap(it => {
223 const f = found.get(it.ref)!
224 const href = plat && terminalHref(f.abs, f.kind, prefs, it.href, plat, it.ref.line, it.ref.col)
225 return href ? [{ href, label: shortLabel(it, false).replace(/\\(.)/g, '$1') }] : []
226 })
227 if (links.length === 0) return drawn
228 return (
229 <Box flexDirection="column">
230 {drawn}
231 <Box paddingLeft={2}>
232 <Text dimColor>
233 {'↳ open '}
234 {links.slice(0, 12).flatMap((l, i) => [i ? ' · ' : '', <Link key={`l${i}`} href={l.href}>{l.label}</Link>])}
235 </Text>
236 </Box>
237 </Box>
238 )
239 }
240
241 const hrefs = [...new Set(all.map(i => i.href))].slice(0, 256)
242 const press = (link: { href: string }) => void openHref($, link.href)
243 const own = prefs.mode === 'inline' || (prefs.mode === 'auto' && drawn.type === 'engine')
244 if (own) {
245 const edits = [...items.map(it => ({ start: it.ref.start, end: it.ref.end, text: linkOf(it) })), ...blockRows(text, blockItems)]
246 // The engine's own reply row: a blank line above and a "● " gutter on a reply's first block.
247 return (
248 <Box flexDirection="row" marginTop={1}>
249 <Box width={2} flexShrink={0}>
250 <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
251 </Box>
252 <Box flexDirection="column" flexGrow={1}>
253 <Markdown key="open-paths" text={applyEdits(text, edits)} pressableLinks={hrefs} onLinkPress={press} />
254 </Box>
255 </Box>
256 )
257 }
258 // Another mod drew the reply: keep its drawing whole, the links in a row beneath it.
259 return (
260 <Box flexDirection="column">
261 {drawn}
262 <Box paddingLeft={2}>
263 <Markdown key="open-paths" dimColor text={linkRow(all)} pressableLinks={hrefs} onLinkPress={press} />
264 </Box>
265 </Box>
266 )
267 }).catch(($, e, next) => next(e)) // never in the way of a reply
268
269 // A Write (or Read/Write/Edit) row: the engine's row, with a line under it that opens the file.
270 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
271 const input = e.props.input as { file_path?: unknown; notebook_path?: unknown } | null
272 const file = typeof input?.file_path === 'string' ? input.file_path : input?.notebook_path
273 if (prefs.mode === 'off' || e.surface !== 'terminal' || !ROW_TOOLS[prefs.toolRows].has(e.props.tool)) return next(e)
274 if (typeof file !== 'string' || e.props.isRunning || e.props.isErrored || e.props.isInterrupted) return next(e)
275 const abs = resolvePath(file, await ctxOf($))
276 if (!abs) return next(e)
277 await lookUp($, [abs])
278 const found = cache.get(abs)?.found
279 if (!found) return next(e)
280 const { Box, Text, Markdown, Link } = $.ui.resolve(e)
281 const drawn = await next(e)
282 const ref: Ref = { start: 0, end: 0, label: abs, path: abs, code: false }
283 const item = itemOf(ref, found.real)
284 const name = shortLabel(item, false)
285 if (e.viewport?.isFullscreen === false) {
286 const plat = await platformOf($)
287 const href = plat && terminalHref(found.real, found.kind, prefs, item.href, plat)
288 if (!href) return drawn
289 return (
290 <Box flexDirection="column">
291 {drawn}
292 <Box paddingLeft={2}>
293 <Text dimColor>
294 {'⎿ open '}
295 <Link href={href}>{name.replace(/\\(.)/g, '$1')}</Link>
296 </Text>
297 </Box>
298 </Box>
299 )
300 }
301 return (
302 <Box flexDirection="column">
303 {drawn}
304 <Box paddingLeft={2}>
305 <Markdown key="open-paths-tool" dimColor text={`⎿ open [${name}](${item.href})`} pressableLinks={[item.href]} onLinkPress={link => void openHref($, link.href)} />
306 </Box>
307 </Box>
308 )
309 }).catch(($, e, next) => next(e))
310}
311hooks/openers.ts 223 lines1// What a click on a path runs. Pure: the argv only, run by register.tsx.
2//
3// The rule is an allow-list. Known document and media types go to the system opener, which picks the
4// type's default app (Word, Preview, Excel). Source, text and script files go to the editor: their
5// default app can be a runner (Python Launcher, a terminal, Windows Script Host). Everything else is
6// revealed in the file manager, never opened: a type not on the lists may launch, install or run.
7// Callers classify by the path's real target, so a symlink named report.pdf cannot open an app.
8
9export type Platform = 'mac' | 'linux' | 'windows' | 'wsl'
10export type Kind = 'file' | 'dir'
11/** What an extension is sent to: the editor, the default app, a reveal, or a named app. */
12export type Target = 'editor' | 'default' | 'reveal' | { app: string }
13
14export type Prefs = {
15 /** A key of EDITORS, `default` for the system's own choice, or an app name (a command on Linux). */
16 editor: string
17 /** Extension (lower case, no dot) to where it goes. */
18 overrides: Record<string, Target>
19 folders: 'files' | 'editor'
20}
21
22export type Action = {
23 how: 'open' | 'editor' | 'reveal' | 'app' | 'refuse'
24 argv: string[]
25 fallback?: string[]
26 /** WSL: the path in argv is turned into a Windows path (`wslpath -w`) before it runs. */
27 windowsPath?: boolean
28 /** Who opens it, for the toast: an app or editor name; absent for the default app. */
29 by?: string
30}
31
32type Editor = {
33 name: string
34 /** The macOS app. */
35 app: string
36 /** The command line launcher, which can go to a line. */
37 cli: string
38 at: (file: string, line: number, col?: number) => string[]
39 /** A URL a terminal hands to the editor itself, for terminals the mod gets no click from. */
40 url?: (file: string, line?: number, col?: number) => string
41}
42
43const colon = (file: string, line: number, col?: number) => `${file}:${line}${col === undefined ? '' : `:${col}`}`
44// Each segment encoded whole, so a `#` or `?` in a name cannot cut the URL short; a drive letter stays as it is.
45const urlPath = (file: string) =>
46 (file.startsWith('/') ? file : `/${file}`).split('/').map((seg, i) => (i === 1 && /^[A-Za-z]:$/.test(seg) ? seg : encodeURIComponent(seg))).join('/')
47const lc = (line?: number, col?: number) => (line === undefined ? '' : `:${line}${col === undefined ? '' : `:${col}`}`)
48
49export const EDITORS: Record<string, Editor> = {
50 vscode: {
51 name: 'Visual Studio Code',
52 app: 'Visual Studio Code',
53 cli: 'code',
54 at: (f, l, c) => ['-g', colon(f, l, c)],
55 url: (f, l, c) => `vscode://file${urlPath(f)}${lc(l, c)}`,
56 },
57 cursor: {
58 name: 'Cursor',
59 app: 'Cursor',
60 cli: 'cursor',
61 at: (f, l, c) => ['-g', colon(f, l, c)],
62 url: (f, l, c) => `cursor://file${urlPath(f)}${lc(l, c)}`,
63 },
64 zed: { name: 'Zed', app: 'Zed', cli: 'zed', at: (f, l, c) => [colon(f, l, c)] },
65 sublime: { name: 'Sublime Text', app: 'Sublime Text', cli: 'subl', at: (f, l, c) => [colon(f, l, c)] },
66 idea: {
67 name: 'IntelliJ IDEA',
68 app: 'IntelliJ IDEA',
69 cli: 'idea',
70 at: (f, l, c) => ['--line', String(l), ...(c === undefined ? [] : ['--column', String(c)]), f],
71 },
72}
73
74const words = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
75
76/** Documents and media: the only types the system opener is ever handed. */
77export const DOC_EXTS = words(`
78 docx doc dotx odt rtf pages txt text pdf epub
79 xlsx xls csv tsv numbers ods pptx ppt key odp
80 png jpg jpeg gif webp heic heif bmp tif tiff svg ico avif psd
81 mp3 wav m4a aac flac ogg oga aiff mp4 m4v mov mkv avi webm
82 html htm
83`)
84
85/** Opened by a terminal or a script host, these run: they go to the editor and nowhere else. */
86export const SCRIPT_EXTS = words('sh bash zsh fish ksh csh tcsh command tool ps1 psm1 bat cmd')
87
88export const EDITOR_EXTS = words(`
89 py pyi pyw ipynb java kt kts scala groovy gradle c h cc cpp cxx hpp hh hxx m mm swift go rs zig
90 js mjs cjs jsx ts mts cts tsx vue svelte astro css scss sass less json jsonc json5 yaml yml toml ini cfg conf
91 env properties xml xsd sql graphql gql proto md mdx markdown rst tex bib sty cls r rmd jl lua rb php pl pm
92 cs fs fsx vb dart ex exs erl hrl hs elm clj cljs edn ml mli nim v sv vhd vhdl tf hcl nix dockerfile
93 makefile cmake mk gitignore gitattributes editorconfig lock log diff patch
94`)
95
96/** Known launchers, installers and auto-running types: an override may not send these to an app. */
97export const LAUNCHER_EXTS = words(`
98 app pkg mpkg dmg iso img terminal workflow action jar exe msi msp mst msix msixbundle appx appxbundle appinstaller
99 scpt scptd applescript fileloc inetloc webloc afploc ftploc vncloc mailloc newsloc rdp ica jnlp mobileconfig shortcut
100 prefpane saver kext plugin bundle appex xpc service com scr vbs vbe js_ jse ws wsf wsh wsc sct hta reg lnk url cpl msc pif
101 chm application appref-ms settingcontent-ms xll scf library-ms search-ms searchconnector-ms theme themepack
102 vhd vhdx diagcab gadget inf ins isp pyc pyo pyz pyzw desktop appimage run deb rpm docm dotm xlsm xltm pptm potm
103`)
104
105/** Extensions an override may not send to the default app or a named app, since that runs them. */
106export const LOCKED_EXTS = new Set([...SCRIPT_EXTS, ...LAUNCHER_EXTS])
107
108/** Characters cmd.exe re-parses, and Explorer's switch separator: a path holding one never reaches either. */
109const CMD_UNSAFE = /[&|<>^%!"\r\n,]/
110
111export const extOf = (abs: string) => {
112 const name = abs.slice(abs.lastIndexOf('/') + 1)
113 const dot = name.lastIndexOf('.')
114 return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
115}
116
117/** True when some folder on the way holds a launcher extension (a file inside Foo.app/Contents). */
118const insideLauncher = (abs: string) => abs.split('/').slice(0, -1).some(seg => LAUNCHER_EXTS.has(extOf(`/${seg}`)))
119
120const parentOf = (abs: string) => abs.slice(0, Math.max(abs.lastIndexOf('/'), 1)) || '/'
121const nameOf = (abs: string) => abs.slice(abs.lastIndexOf('/') + 1)
122const winPath = (abs: string) => abs.replace(/\//g, '\\')
123const refuse = (why: string): Action => ({ how: 'refuse', argv: [], by: why })
124
125function opener(platform: Platform, abs: string): Action {
126 switch (platform) {
127 case 'mac':
128 return { how: 'open', argv: ['open', abs] }
129 case 'linux':
130 return { how: 'open', argv: ['xdg-open', abs] }
131 case 'windows':
132 // Explorer opens a document in its app without a shell; cmd.exe would re-parse the name.
133 return CMD_UNSAFE.test(abs) ? refuse('its name holds characters Windows would run') : { how: 'open', argv: ['explorer.exe', winPath(abs)] }
134 case 'wsl':
135 return CMD_UNSAFE.test(abs)
136 ? refuse('its name holds characters Windows would run')
137 : { how: 'open', argv: ['wslview', abs], fallback: ['explorer.exe', abs], windowsPath: true }
138 }
139}
140
141function revealer(platform: Platform, abs: string): Action {
142 switch (platform) {
143 case 'mac':
144 return { how: 'reveal', argv: ['open', '-R', abs], by: 'Finder' }
145 case 'linux':
146 // Selecting a file needs a FileManager1 D-Bus service; its folder opens everywhere.
147 return { how: 'reveal', argv: ['xdg-open', parentOf(abs)], by: 'the file manager' }
148 case 'windows':
149 return CMD_UNSAFE.test(abs) ? refuse('its name holds characters Explorer would misread') : { how: 'reveal', argv: ['explorer.exe', `/select,${winPath(abs)}`], by: 'Explorer' }
150 case 'wsl':
151 return CMD_UNSAFE.test(abs) ? refuse('its name holds characters Explorer would misread') : { how: 'reveal', argv: ['explorer.exe', `/select,${abs}`], windowsPath: true, by: 'Explorer' }
152 }
153}
154
155function withApp(platform: Platform, app: string, abs: string): Action {
156 if (platform === 'mac') return { how: 'app', argv: ['open', '-a', app, abs], by: app }
157 if (platform === 'windows')
158 return CMD_UNSAFE.test(abs) || CMD_UNSAFE.test(app) ? refuse('its name holds characters Windows would run') : { how: 'app', argv: ['cmd', '/c', app, winPath(abs)], by: app }
159 return { how: 'app', argv: [app, abs], by: app }
160}
161
162function inEditor(platform: Platform, prefs: Prefs, abs: string, line?: number, col?: number): Action {
163 const ed = EDITORS[prefs.editor]
164 if (prefs.editor === 'default') {
165 // A code file's default app can be its runner: the plain text editor on a Mac, a reveal elsewhere.
166 return platform === 'mac' ? { how: 'editor', argv: ['open', '-t', abs], by: 'the text editor' } : revealer(platform, abs)
167 }
168 if (!ed) {
169 const byApp = withApp(platform, prefs.editor, abs)
170 return byApp.how === 'refuse' ? byApp : { ...byApp, how: 'editor' }
171 }
172 if (platform === 'windows') {
173 // Editor launchers on Windows are .cmd shims, so cmd.exe reads the path: refuse what it would run.
174 if (CMD_UNSAFE.test(abs)) return refuse('its name holds characters Windows would run')
175 const f = winPath(abs)
176 return { how: 'editor', argv: ['cmd', '/c', ed.cli, ...(line === undefined ? [f] : ed.at(f, line, col))], by: ed.name }
177 }
178 const byCli = line === undefined ? [ed.cli, abs] : [ed.cli, ...ed.at(abs, line, col)]
179 if (platform === 'mac') {
180 const app = ['open', '-a', ed.app, abs]
181 return line === undefined ? { how: 'editor', argv: app, by: ed.name } : { how: 'editor', argv: byCli, fallback: app, by: ed.name }
182 }
183 return { how: 'editor', argv: byCli, by: ed.name }
184}
185
186/** `abs` is the real path, every symlink followed: the name it was written under says nothing safe. */
187export function actionFor(abs: string, kind: Kind, prefs: Prefs, platform: Platform = 'mac', line?: number, col?: number): Action {
188 const ext = extOf(abs)
189 if (LAUNCHER_EXTS.has(ext) || insideLauncher(abs)) return revealer(platform, abs)
190 if (kind === 'dir') return prefs.folders === 'editor' ? inEditor(platform, prefs, abs) : opener(platform, abs)
191
192 const set = prefs.overrides[ext]
193 if (set !== undefined && !(LOCKED_EXTS.has(ext) && (set === 'default' || typeof set === 'object'))) {
194 if (set === 'editor') return inEditor(platform, prefs, abs, line, col)
195 if (set === 'reveal') return revealer(platform, abs)
196 if (set === 'default') return opener(platform, abs)
197 return withApp(platform, set.app, abs)
198 }
199 // A file with no extension (Makefile, LICENSE, a script) is text far more often than not.
200 if (ext === '' || SCRIPT_EXTS.has(ext) || EDITOR_EXTS.has(ext)) return inEditor(platform, prefs, abs, line, col)
201 if (DOC_EXTS.has(ext)) return opener(platform, abs)
202 return revealer(platform, abs)
203}
204
205/** The words a toast says: `Opening Handout.docx`, `… in Visual Studio Code`, `Showing X.app in Finder`. */
206export function describe(action: Action, abs: string, line?: number): string {
207 const name = nameOf(abs) + (line === undefined ? '' : `:${line}`)
208 if (action.how === 'refuse') return `Not opening ${name}: ${action.by}`
209 if (action.how === 'reveal') return `Showing ${name} in ${action.by ?? 'the file manager'}`
210 return action.by ? `Opening ${name} in ${action.by}` : `Opening ${name}`
211}
212
213/**
214 * A link a terminal can follow by itself (no click reaches the mod): a file URL for documents and
215 * folders, the editor's own URL for code, nothing for the rest, since the terminal would open it.
216 */
217export function terminalHref(abs: string, kind: Kind, prefs: Prefs, fileHref: string, platform: Platform, line?: number, col?: number): string | undefined {
218 const action = actionFor(abs, kind, prefs, platform, line, col)
219 if (action.how === 'open') return fileHref
220 if (action.how === 'editor') return EDITORS[prefs.editor]?.url?.(abs, line, col)
221 return undefined
222}
223hooks/paths.ts 229 lines1// Finding file paths in a reply's markdown and turning the ones that exist into links.
2// Pure: no engine calls, so the tests can pin every rule.
3
4export type Ctx = { home?: string; cwd?: string }
5
6/** One path written in the text: where it sits, what was written, and the line it points at. */
7export type Ref = {
8 start: number
9 end: number
10 /** The text as written, backticks and :line included: what the link draws. */
11 label: string
12 /** The path alone, as written (`~/x`, `src/a.ts`), no :line. */
13 path: string
14 line?: number
15 col?: number
16 /** Written inside backticks. */
17 code: boolean
18}
19
20// Any indentation and quote markers: a fence nested in a list item or a blockquote is still code.
21const FENCE = /^[ \t]*(?:>[ \t]*)*(`{3,}|~{3,})/
22// Markdown links, autolinks and bare URLs: never touched.
23const MASK = /\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>|\b[a-z][a-z0-9+.-]*:\/\/[^\s<>()]+/gi
24const CODE = /`([^`\n]+)`/g
25// A run of characters a path can hold outside backticks; spaces and brackets end it.
26const TOKEN = /[^\s`'"<>()[\]{}|,;]+/g
27// `:42`, `:42:7`, and a range `:10-20` (its first line).
28const LINE = /^(.*?)(?::(\d+)(?:-\d+|:(\d+))?)?$/
29const BARE_NAME = /^[\w@+-][\w.@+-]*\.([A-Za-z][A-Za-z0-9]{0,9})$/
30const NOT_NAMES = new Set(['e.g', 'i.e', 'a.k.a', 'p.s', 'vs', 'etc'])
31/** A Windows absolute path: `C:\\Users\\me` or `C:/Users/me`. */
32const DRIVE = /^[A-Za-z]:[\\/]/
33
34/** Whether written text looks like a path: has a slash, starts at home, or is a name with an extension. */
35function isPathy(s: string, allowSpaces: boolean): boolean {
36 if (!s || s.length > 1024 || s.includes('://') || s.startsWith('-')) return false
37 if (/\s/.test(s) && !allowSpaces) return false
38 if (s === '~' || s.startsWith('~/') || DRIVE.test(s)) return true
39 if (s.includes('/')) return !/^[\d/.:-]+$/.test(s) && /[^/.]/.test(s)
40 // In backticks a name may hold spaces (`My Notes.docx`); the existence check weeds out commands.
41 const m = BARE_NAME.exec(allowSpaces ? s.replace(/ /g, '_') : s)
42 if (!m) return false
43 const name = s.slice(0, -m[1]!.length - 1)
44 return !/^[\d.]+$/.test(name) && !NOT_NAMES.has(s.toLowerCase())
45}
46
47function splitLine(written: string): { path: string; line?: number; col?: number } {
48 const m = LINE.exec(written)!
49 const path = m[1]!
50 if (m[2] === undefined || !path) return { path: written }
51 return { path, line: Number(m[2]), col: m[3] === undefined ? undefined : Number(m[3]) }
52}
53
54function lineRefs(text: string, offset: number, out: Ref[]): void {
55 const masked: [number, number][] = []
56 for (const m of text.matchAll(MASK)) masked.push([m.index, m.index + m[0].length])
57 const isMasked = (s: number, e: number) => masked.some(([a, b]) => s < b && e > a)
58
59 for (const m of text.matchAll(CODE)) {
60 const s = m.index
61 const e = s + m[0].length
62 if (isMasked(s, e)) continue
63 masked.push([s, e])
64 const inner = m[1]!.trim()
65 const { path, line, col } = splitLine(inner)
66 if (isPathy(path, true)) out.push({ start: offset + s, end: offset + e, label: m[0], path, line, col, code: true })
67 }
68
69 for (const m of text.matchAll(TOKEN)) {
70 let s = m.index
71 let tok = m[0]
72 const lead = /^\*+/.exec(tok)?.[0].length ?? 0
73 s += lead
74 tok = tok.slice(lead).replace(/[*.,:;!?]+$/, '')
75 if (!tok || isMasked(s, s + tok.length)) continue
76 const { path, line, col } = splitLine(tok)
77 if (isPathy(path, false)) out.push({ start: offset + s, end: offset + s + tok.length, label: tok, path, line, col, code: false })
78 }
79}
80
81/** A closed fenced code block's paths, and where the block ends (just past its closing fence line). */
82export type Block = { end: number; refs: Ref[] }
83
84function scan(text: string): { refs: Ref[]; blocks: Block[] } {
85 const refs: Ref[] = []
86 const blocks: Block[] = []
87 let fence: { mark: string; refs: Ref[] } | undefined
88 let offset = 0
89 for (const line of text.split('\n')) {
90 const f = FENCE.exec(line)?.[1]
91 if (fence) {
92 if (f && f[0] === fence.mark[0] && f.length >= fence.mark.length && line.replace(/^[ \t]*(?:>[ \t]*)*/, '').trim() === f) {
93 if (fence.refs.length) blocks.push({ end: Math.min(text.length, offset + line.length + 1), refs: fence.refs })
94 fence = undefined
95 } else {
96 lineRefs(line, offset, fence.refs)
97 }
98 } else if (f) {
99 fence = { mark: f, refs: [] }
100 } else {
101 lineRefs(line, offset, refs)
102 }
103 offset += line.length + 1
104 }
105 // A block still open (a reply streaming in) gives nothing yet.
106 return { refs: refs.sort((a, b) => a.start - b.start), blocks }
107}
108
109/** Every path written in the markdown, outside fenced code, links and URLs, in order. */
110export function findRefs(text: string): Ref[] {
111 return scan(text).refs
112}
113
114/** The paths written inside each closed fenced code block, the block left as written. */
115export function findBlocks(text: string): Block[] {
116 return scan(text).blocks
117}
118
119/**
120 * The home folder, found without reading the environment: an installed mod lives under
121 * `<home>/.claude/`, else the working directory sits under `/Users/<name>`, `/home/<name>`
122 * or `C:/Users/<name>`. Undefined when neither says, and then `~` paths are not linked.
123 */
124export function homeOf(pluginRoot: string | undefined, cwd: string | undefined): string | undefined {
125 const fwd = (p?: string) => p?.replace(/\\/g, '/')
126 const root = fwd(pluginRoot)
127 const at = root?.indexOf('/.claude/') ?? -1
128 if (root && at > 0) return root.slice(0, at)
129 const m = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root|[A-Za-z]:\/Users\/[^/]+)(?=\/|$)/.exec(fwd(cwd) ?? '')
130 return m?.[1]
131}
132
133/** True for a Windows path (`C:\\…` or `C:/…`). */
134export const isWindowsPath = (p: string | undefined) => !!p && /^[A-Za-z]:[\\/]/.test(p)
135
136/** The absolute path a written path names, or undefined when it cannot be resolved or is a network path. */
137export function resolvePath(p: string, ctx: Ctx): string | undefined {
138 if (p.includes('\0')) return undefined
139 let abs: string
140 if (DRIVE.test(p)) {
141 abs = p
142 } else if (p === '~' || p.startsWith('~/')) {
143 if (!ctx.home) return undefined
144 abs = ctx.home + p.slice(1)
145 } else if (p.startsWith('/')) {
146 if (p.startsWith('//')) return undefined
147 abs = p
148 } else {
149 if (!ctx.cwd) return undefined
150 abs = `${ctx.cwd}/${p}`
151 }
152 abs = abs.replace(/\\/g, '/')
153 let root = '/'
154 if (DRIVE.test(abs)) {
155 root = `${abs[0]!.toUpperCase()}:/`
156 abs = abs.slice(3)
157 } else if (abs.startsWith('//')) {
158 return undefined
159 }
160 const segs: string[] = []
161 for (const seg of abs.split('/')) {
162 if (!seg || seg === '.') continue
163 if (seg === '..') segs.pop()
164 else segs.push(seg)
165 }
166 const norm = root + segs.join('/')
167 // Opening a network path can hand credentials to the host it names.
168 if (/^\/(net|Network)(\/|$)/.test(norm)) return undefined
169 return norm
170}
171
172const encodeSeg = (s: string) => encodeURIComponent(s).replace(/[()]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
173
174/** A file: URL for the path, the line in its fragment. The mod answers the click, so the URL is its key. */
175export function hrefOf(abs: string, line?: number, col?: number): string {
176 const frag = line === undefined ? '' : `#L${line}${col === undefined ? '' : `:${col}`}`
177 const path = abs.split('/').map(encodeSeg).join('/').replace(/^([A-Za-z])%3A/, '/$1:')
178 return `file://${path}${frag}`
179}
180
181export const escapeMd = (s: string) => s.replace(/[\\`*_[\]<>~]/g, c => `\\${c}`)
182
183export type Edit = { start: number; end: number; text: string }
184export type Item = { ref: Ref; href: string }
185
186/** Applies non-overlapping edits, each against the original offsets. */
187export function applyEdits(text: string, edits: Edit[]): string {
188 let out = text
189 for (const ed of [...edits].sort((a, b) => b.start - a.start || b.end - a.end)) out = out.slice(0, ed.start) + ed.text + out.slice(ed.end)
190 return out
191}
192
193/** A markdown link to the item, its label as written. */
194export const linkOf = ({ ref, href }: Item) => `[${ref.code ? ref.label : escapeMd(ref.label)}](${href})`
195
196/** The short name an item goes by in a row of links: the file name and line, or the path as written when names clash. */
197export function shortLabel(item: Item, clash: boolean): string {
198 const at = item.ref.line === undefined ? '' : `:${item.ref.line}${item.ref.col === undefined ? '' : `:${item.ref.col}`}`
199 const name = clash ? item.ref.path : item.ref.path.replace(/\/+$/, '').split('/').pop() || item.ref.path
200 return escapeMd(name + at)
201}
202
203/** One dim row of links: `↳ open a.docx · b.py:3`, each item once. */
204export function linkRow(items: Item[], max = 12): string {
205 const seen = new Map<string, Item>()
206 for (const it of items) if (!seen.has(it.href)) seen.set(it.href, it)
207 const list = [...seen.values()]
208 const names = list.map(it => it.ref.path.replace(/\/+$/, '').split('/').pop())
209 const parts = list.slice(0, max).map((it, i) => `[${shortLabel(it, names.filter(n => n === names[i]).length > 1)}](${it.href})`)
210 const more = list.length > max ? ` · +${list.length - max} more` : ''
211 return `↳ open ${parts.join(' · ')}${more}`
212}
213
214/** The text with each given ref replaced by a markdown link to its href. */
215export function linkify(text: string, items: Item[]): string {
216 return applyEdits(text, items.map(it => ({ start: it.ref.start, end: it.ref.end, text: linkOf(it) })))
217}
218
219/** Edits that put a row of links after each code block that names files. */
220export function blockRows(text: string, blocks: { end: number; items: Item[] }[]): Edit[] {
221 return blocks
222 .filter(b => b.items.length)
223 .map(b => {
224 const lead = b.end > 0 && text[b.end - 1] !== '\n' ? '\n' : ''
225 const tail = b.end < text.length && text[b.end] !== '\n' ? '\n' : ''
226 return { start: b.end, end: b.end, text: `${lead}${linkRow(b.items)}\n${tail}` }
227 })
228}
229hooks/prefs.ts 122 lines1// Settings: defaults, what `/open-paths` changes, and the report it prints. Pure.
2
3import { EDITORS, LOCKED_EXTS } from './openers'
4import type { Prefs as OpenPrefs, Target } from './openers'
5
6export type Mode = 'auto' | 'inline' | 'below' | 'off'
7export type ToolRows = 'write' | 'all' | 'off'
8
9export type Prefs = OpenPrefs & {
10 mode: Mode
11 toolRows: ToolRows
12 toast: boolean
13}
14
15export const DEFAULTS: Prefs = { mode: 'inline', editor: 'vscode', overrides: {}, folders: 'files', toolRows: 'write', toast: true }
16
17const MODES: readonly Mode[] = ['auto', 'inline', 'below', 'off']
18const TOOL_ROWS: readonly ToolRows[] = ['write', 'all', 'off']
19
20const isTarget = (v: unknown): v is Target =>
21 v === 'editor' || v === 'default' || v === 'reveal' || (typeof v === 'object' && v !== null && typeof (v as { app?: unknown }).app === 'string')
22
23/** The manifest's options under what `/open-paths` stored, each value checked. */
24export function prefsOf(options: Record<string, unknown>, stored: unknown): Prefs {
25 const s = (stored && typeof stored === 'object' ? stored : {}) as Partial<Record<keyof Prefs, unknown>>
26 const editor = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
27 const overrides: Record<string, Target> = Object.create(null)
28 if (s.overrides && typeof s.overrides === 'object')
29 for (const [k, v] of Object.entries(s.overrides as Record<string, unknown>)) if (isTarget(v)) overrides[k] = v
30 return {
31 mode: MODES.includes(s.mode as Mode) ? (s.mode as Mode) : DEFAULTS.mode,
32 editor: editor(s.editor) ?? editor(options.editor) ?? DEFAULTS.editor,
33 overrides,
34 folders: s.folders === 'editor' || s.folders === 'files' ? s.folders : DEFAULTS.folders,
35 toolRows: TOOL_ROWS.includes(s.toolRows as ToolRows) ? (s.toolRows as ToolRows) : DEFAULTS.toolRows,
36 toast: typeof s.toast === 'boolean' ? s.toast : DEFAULTS.toast,
37 }
38}
39
40export const HELP = [
41 'Commands:',
42 ' /open-paths this report',
43 ' /open-paths mode inline|auto|below|off links in the reply; auto keeps another mod\'s drawing and adds a row; below always a row; off',
44 ` /open-paths editor <name|App Name> ${Object.keys(EDITORS).join(', ')}, default, or any app`,
45 ' /open-paths set <ext> <target> target: editor, default, reveal, or an app name',
46 ' /open-paths unset <ext> back to the built-in rule for that type',
47 ' /open-paths folders files|editor folders open in the file manager or the editor',
48 ' /open-paths tools write|all|off an open line under Write, or Read/Write/Edit, tool rows',
49 ' /open-paths toast on|off say what is opening',
50 ' /open-paths reset every setting back to its default',
51].join('\n')
52
53const show = (t: Target) => (typeof t === 'object' ? t.app : t)
54
55export function report(p: Prefs): string {
56 const ov = Object.entries(p.overrides)
57 return [
58 `open-paths: mode ${p.mode} · editor ${p.editor} · folders ${p.folders} · tool rows ${p.toolRows} · toast ${p.toast ? 'on' : 'off'}`,
59 ov.length ? `overrides: ${ov.map(([k, v]) => `.${k} → ${show(v)}`).join(', ')}` : 'overrides: none',
60 '',
61 HELP,
62 ].join('\n')
63}
64
65/** What a `/open-paths` line does: the new settings and what to say, or just what to say. */
66export function command(p: Prefs, args: string): { prefs?: Prefs; text: string } {
67 const raw = args.trim()
68 const [cmd = '', ...rest] = raw.split(/\s+/)
69 const arg = rest.join(' ')
70 const say = (prefs: Prefs, text: string) => ({ prefs, text: `open-paths: ${text}` })
71 const onOff = (v: string, cur: boolean) => (v === 'on' ? true : v === 'off' ? false : !cur)
72
73 switch (cmd.toLowerCase()) {
74 case '':
75 case 'help':
76 case 'status':
77 return { text: report(p) }
78 case 'mode':
79 return MODES.includes(arg as Mode) ? say({ ...p, mode: arg as Mode }, `mode ${arg}`) : { text: `open-paths: mode is one of ${MODES.join(', ')}` }
80 case 'auto':
81 case 'inline':
82 case 'below':
83 case 'off':
84 return say({ ...p, mode: cmd.toLowerCase() as Mode }, `mode ${cmd.toLowerCase()}`)
85 case 'on':
86 return say({ ...p, mode: DEFAULTS.mode }, `mode ${DEFAULTS.mode}`)
87 case 'editor': {
88 if (!arg) return { text: `open-paths: editor ${p.editor}` }
89 const key = arg.toLowerCase()
90 const editor = key in EDITORS || key === 'default' ? key : arg
91 return say({ ...p, editor }, `code and text open in ${EDITORS[editor]?.name ?? editor}`)
92 }
93 case 'set': {
94 const [ext0 = '', ...to] = rest
95 const ext = ext0.replace(/^\./, '').toLowerCase()
96 const t = to.join(' ')
97 if (!ext || !t) return { text: 'open-paths: /open-paths set <ext> <editor|default|reveal|App Name>' }
98 const target: Target = ['editor', 'default', 'reveal'].includes(t.toLowerCase()) ? (t.toLowerCase() as Target) : { app: t }
99 if (LOCKED_EXTS.has(ext) && (target === 'default' || typeof target === 'object'))
100 return { text: `open-paths: .${ext} runs or installs something when opened; it can go to the editor or be revealed, nothing else` }
101 return say({ ...p, overrides: { ...p.overrides, [ext]: target } }, `.${ext} → ${show(target)}`)
102 }
103 case 'unset': {
104 const ext = arg.replace(/^\./, '').toLowerCase()
105 const { [ext]: _gone, ...overrides } = p.overrides
106 return say({ ...p, overrides }, `.${ext} back to the built-in rule`)
107 }
108 case 'folders':
109 return arg === 'files' || arg === 'editor' ? say({ ...p, folders: arg }, `folders open in ${arg === 'files' ? 'the file manager' : 'the editor'}`) : { text: 'open-paths: folders files|editor' }
110 case 'tools':
111 return TOOL_ROWS.includes(arg as ToolRows) ? say({ ...p, toolRows: arg as ToolRows }, `tool rows ${arg}`) : { text: `open-paths: tools ${TOOL_ROWS.join('|')}` }
112 case 'toast': {
113 const toast = onOff(arg, p.toast)
114 return say({ ...p, toast }, `toast ${toast ? 'on' : 'off'}`)
115 }
116 case 'reset':
117 return say({ ...DEFAULTS }, 'settings reset')
118 default:
119 return { text: `open-paths: unknown command "${cmd}"\n\n${HELP}` }
120 }
121}
122hooks/touched.ts 37 lines1// The files Claude's tools read or wrote, by name, newest first: so "see Handout.docx" finds the
2// ~/output/Handout.docx it wrote an hour ago, outside the working directory. Pure.
3
4/** File name to absolute paths, newest first. */
5export type Touched = Record<string, string[]>
6
7const PER_NAME = 5
8const MAX_NAMES = 500
9
10const nameOf = (abs: string) => abs.replace(/\/+$/, '').slice(abs.replace(/\/+$/, '').lastIndexOf('/') + 1)
11
12/** The index with `abs` as the newest under its name; the oldest names leave past the cap. */
13export function remember(index: Touched, abs: string): Touched {
14 const name = nameOf(abs)
15 if (!name || (!abs.startsWith('/') && !/^[A-Za-z]:\//.test(abs))) return index
16 // A null-prototype copy, so a file named like one of Object's built-in keys is just a name here.
17 const next: Touched = Object.assign(Object.create(null) as Touched, index)
18 const had = Object.prototype.hasOwnProperty.call(index, name) ? index[name]! : []
19 delete next[name]
20 next[name] = [abs, ...had.filter(p => p !== abs)].slice(0, PER_NAME)
21 const names = Object.keys(next)
22 if (names.length > MAX_NAMES) for (const n of names.slice(0, names.length - MAX_NAMES)) delete next[n]
23 return next
24}
25
26/**
27 * The touched paths a written path may mean, newest first: those with its name whose
28 * tail matches what was written (`docs/a.md` matches `/x/docs/a.md`, not `/x/notes/a.md`).
29 */
30export function recall(index: Touched, written: string): string[] {
31 const w = written.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
32 if (!w || w.startsWith('/') || w.startsWith('~') || w.includes('..')) return []
33 const name = nameOf(w)
34 const list = Object.prototype.hasOwnProperty.call(index, name) ? index[name]! : []
35 return list.filter(abs => abs === w || abs.endsWith(`/${w}`))
36}
37