SLOPSHOPPER

Open Paths

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.

newrowsguardcommandtoastprocess
v0.2.0MITupdated 2026-10-15kiankhooban/open-paths
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-paths
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /open-paths ⎿ open-paths: open-paths: mode inline · editor vscode · folders files · tool rows write · toast on ⎿ open-paths: overrides: none ⎿ open-paths: ⎿ open-paths: Commands: ⎿ open-paths: /open-paths this report ⎿ open-paths: /open-paths mode inline|auto|below|off links in the reply; auto keeps another mod's drawing and adds a row; ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

open-paths

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.

open-paths demo

Install

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

What opens where

You clickIt opens in
.docx .pdf .xlsx .pptx .png .csv .html and every other documentthe file type's default app
.py .java .ts .c .go .rs .json .md and 100+ other code and text typesyour editor (VS Code by default), at the line when one is written: app.py:42, main.c:10:5
a folderFinder, Explorer or your file manager
.sh .command .ps1 and other scriptsyour editor, never their default app: opened by a terminal, a script runs
.app .pkg .dmg .exe, and any type not on the lists aboverevealed in the file manager, never launched

What it links

  • Absolute paths, ~/ paths and paths relative to Claude's working directory.
  • Paths in backticks, including ones with spaces: ` ~/My Drive/Notes Q1.docx `.
  • file:line and file:line:col.
  • Paths inside code blocks get a ↳ open row under the block, so the code stays copyable.
  • Bare file names Claude mentions after its tools touched them: Claude writes ~/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.

Settings

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.

Works with other mods

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.

Platforms

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.

Safety

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:

  • Allow-list, not deny-list. Only known document and media types go to their default app. Code goes to your editor. Everything else is revealed in the file manager, so a type nobody thought of cannot launch.
  • Real paths. Every symlink is followed before deciding: a link named report.pdf that leads to an app is revealed, not opened.
  • No shell. Commands run by argument vector. On Windows, documents open through Explorer; a file name holding characters cmd.exe would act on (& | ^ % !) is refused, not passed on.
  • No network paths. Paths that land on a network share are never linked: opening one can send credentials to its host.
  • Nothing on an unknown system. If the platform cannot be identified, nothing opens.

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.

What it reads, runs and sends

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:

HookWhat it does
ui.render on AssistantMessageFinds 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 ToolUseAdds an ⎿ open line under Write (or Read/Write/Edit) tool rows.
tool.callAfter 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.startLoads settings and registers the /open-paths command.
command.run for /open-pathsShows 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:

ProgramWhenWhy
uname -sronce per session, on the first clickto tell macOS, Linux and WSL apart
open <file>, open -a <app> <file>, open -R <file>, open -t <file>on a click, macOSopen in the default app, a chosen app, reveal in Finder, or the plain text editor
xdg-open <file or folder>on a click, Linuxopen in the default app, or show the folder
explorer.exe <file>, explorer.exe /select,<file>on a click, Windows and WSLopen in the default app, or reveal
wslview <file>, wslpath -w <file>on a click, WSLopen through Windows, and translate the path for it
code -g, cursor -g, zed, subl, idea --lineon a click on code, if that editor is chosenopen the editor at the line
cmd /c <editor command> <file>on a click on code, Windowseditor 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.

How it works

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.

Development

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

License

MIT

Source 5 files
hooks/register.tsx 311 lines
1import 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}
311
hooks/openers.ts 223 lines
1// 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}
223
hooks/paths.ts 229 lines
1// 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}
229
hooks/prefs.ts 122 lines
1// 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}
122
hooks/touched.ts 37 lines
1// 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