SLOPSHOPPER

file-dock

붙여넣은 이미지, 내가 적은 경로, Claude 답변에 나온 파일과 폴더(절대·상대 경로)를 입력창 위 버튼으로 모아 기본 프로그램이나 파일 탐색기로 여는 Mod

newbandguardtoastpromptprocess
★ 1v0.4.0MITupdated 2026-10-07Changroro/plugins/plugins/file-dock
A shopper browsing a rack in a slop shop
README

file-dock

Claude가 말한 경로가 입력창 위 버튼이 됩니다. 누르면 그 PC의 기본 프로그램이나 파일 탐색기로 바로 열립니다.

마켓플레이스 · 플러그인 소스

<img src="demo/file-dock.gif" alt="file-dock 데모: 경로가 입력창 위 버튼으로 모이고, 누르면 파일과 폴더가 열린다" width="780"/>

Claude Code mods로 만든 플러그인입니다. Claude Code 2.1.287 이상이 필요합니다.

설치

/plugin marketplace add Changroro/plugins
/plugin install file-dock@changroro
/reload-plugins

버튼이 생기는 경로

구분대상바뀌는 시점
나붙여넣은 이미지([Image #1])입력창에서 지우면 사라지고, /clear 하면 비워짐
나내가 프롬프트에 적은 파일·폴더 경로대화 동안 쌓이며 최근 5개 표시
Claude마지막 답변에 나온 파일·폴더 경로, 그 턴에 Claude가 Write·Edit로 만든 파일답변마다 새로 바뀜

절대 경로, 상대 경로, ~/…, Windows C:\…, file://…, 따옴표·백틱·굵게 표시 안의 경로를 인식합니다. 실제로 존재하는 파일과 폴더만 버튼이 됩니다. 파일은 문서·이미지·영상·음성 확장자(pdf, hwp, docx, pptx, xlsx, md, png, jpg, mp4 등)만 대상으로 하고, 코드 파일은 버튼으로 만들지 않습니다.

여는 방법

버튼을 클릭하거나, ctrl+x tab으로 입력창 위 띠로 이동한 뒤 방향키와 Enter로 누릅니다. 성공하면 📂 이름 열었습니다 알림이 뜹니다.

OS여는 명령
Linuxxdg-open
macOSopen
Windowsrundll32 url.dll,FileProtocolHandler

접근 범위

  • 읽기: 경로가 실제로 있는지와 종류(파일·폴더), Claude Code가 붙여넣은 이미지를 두는 임시 폴더 목록, 입력창 내용(붙여넣은 이미지 번호 확인용)
  • 실행: OS 판별용 uname -s, id -u, 그리고 버튼을 눌렀을 때 위의 여는 명령
  • 쓰기·네트워크·모델 호출: 없음

알려진 한계

  • Linux에서 실제로 확인했고, macOS·Windows는 테스트로만 확인했습니다.
  • 붙여넣은 이미지 위치는 Claude Code 내부 규칙(<임시 폴더>/claude-<uid>/<프로젝트>/<세션>/images/)을 따릅니다. Claude Code가 이 규칙을 바꾸면 이미지 버튼이 뜨지 않을 수 있습니다.
  • 공백이 든 경로를 따옴표 없이 쓰면 공백 앞에서 끊겨 버튼이 생기지 않을 수 있습니다.

개발

claude plugin validate --strict plugins/file-dock
claude plugin test plugins/file-dock
claude --plugin-dir ./plugins/file-dock

데모 GIF는 demo/file-dock.tape를 VHS로 녹화했습니다.

Source 4 files
hooks/register.tsx 154 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { candidates, isShelved, pick, refsOf, resolve, slug, visible } from './paths'
6import type { Os } from './platform'
7import { baseName, openCommand, tmpRoot } from './platform'
8
9const pasted = atom({ plugin: 'file-dock', key: 'pasted' } as const, [])
10const draft = atom({ plugin: 'file-dock', key: 'draft' } as const, [])
11const sent = atom({ plugin: 'file-dock', key: 'sent' } as const, [])
12const mine = atom({ plugin: 'file-dock', key: 'mine' } as const, [])
13const claude = atom({ plugin: 'file-dock', key: 'claude' } as const, [])
14const SHOWN = 5
15const written = new Set<string>()
16const known = new Map<string, { entry: Entry | null; at: number }>()
17let os: Os = 'linux'
18let home = ''
19
20async function osOf($: EngineInterface): Promise<Os> {
21  if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
22  return (await $.process.run(['uname', '-s'])).stdout.trim() === 'Darwin' ? 'mac' : 'linux'
23}
24
25async function lookup($: EngineInterface, raw: string, cwd: string) {
26  const path = resolve(raw, cwd, home)
27  const now = await $.clock.now()
28  const cached = known.get(path)
29  if (cached && (cached.entry || now - cached.at < 5_000)) return cached.entry
30  const stat = (await $.fs.exists(path)) ? await $.fs.stat(path) : undefined
31  const entry = stat?.kind === 'dir' ? { path, isDir: true } : stat?.kind === 'file' ? { path, isDir: false } : null
32  known.set(path, { entry, at: now })
33  return entry
34}
35
36async function existing($: EngineInterface, text: string) {
37  const cwd = await $.session.cwd()
38  const found: Entry[] = []
39  for (const raw of candidates(text)) {
40    const entry = await lookup($, raw, cwd)
41    if (entry && (entry.isDir || isShelved(entry.path)) && !found.some(f => f.path === entry.path)) found.push(entry)
42  }
43  return found
44}
45
46const labelOf = (entry: Entry) => (entry.isDir ? `${baseName(entry.path.replace(/[\\/]$/, ''))}/` : baseName(entry.path))
47
48async function findImages($: EngineInterface, base: string, cwd: string, sessionId: string) {
49  const guess = `${base}/${slug(cwd)}/${sessionId}/images`
50  if (await $.fs.exists(guess)) return guess
51  if (!(await $.fs.exists(base))) return undefined
52  for (const project of await $.fs.list(base)) {
53    const dir = `${base}/${project.name}/${sessionId}/images`
54    if (project.kind === 'dir' && (await $.fs.exists(dir))) return dir
55  }
56  return undefined
57}
58
59async function open($: EngineInterface, path: string) {
60  const ran = await $.process.run(openCommand(os, path), { timeoutMs: 10_000 })
61  $.ui.toast(ran.exitCode === 0 ? `📂 ${baseName(path)} 열었습니다` : `file-dock: 열지 못했습니다 (${ran.stderr.trim() || `exit ${ran.exitCode}`})`)
62}
63
64export const register: Register = on => {
65  let polling = false
66
67  on('session.start', async ($, e, next) => {
68    if (polling) return next(e)
69    polling = true
70    os = await osOf($)
71    home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
72    const env = {
73      CLAUDE_CODE_TMPDIR: await $.env.get('CLAUDE_CODE_TMPDIR'),
74      TMPDIR: await $.env.get('TMPDIR'),
75      TMP: await $.env.get('TMP'),
76      TEMP: await $.env.get('TEMP'),
77      SystemRoot: await $.env.get('SystemRoot'),
78      windir: await $.env.get('windir'),
79    }
80    const uid = os === 'windows' ? '0' : (await $.process.run(['id', '-u'])).stdout.trim()
81    const base = `${tmpRoot(os, env).replace(/\\/g, '/')}/claude-${uid}`
82    const cwd = e.cwd
83    const found = new Map<string, string>()
84    let seen = ''
85    $.clock.every(2_000, async () => {
86      const sessionId = await $.session.id()
87      const dir = found.get(sessionId) ?? (await findImages($, base, cwd, sessionId))
88      if (dir) found.set(sessionId, dir)
89      const shots = dir ? pick(await $.fs.list(dir), dir) : []
90      const refs = refsOf((await $.prompt.read()).text)
91      const key = `${shots.map(s => `${s.path}:${s.mtimeMs}`).join('|')}#${refs.join(',')}`
92      if (key === seen) return
93      seen = key
94      await update($, pasted, () => shots)
95      await update($, draft, () => refs)
96    })
97    return next(e)
98  })
99
100  on('prompt.submit', async ($, e, next) => {
101    const typed = await read($, draft)
102    const refs = refsOf(e.text)
103    const result = await next(e)
104    await update($, sent, list => [...new Set([...list, ...(refs.length ? refs : typed)])])
105    await update($, draft, () => [])
106    const paths = await existing($, e.text)
107    if (paths.length) await update($, mine, list => [...list.filter(old => !paths.some(p => p.path === old.path)), ...paths].slice(-20))
108    return result
109  }).catch(($, e, next) => next(e))
110
111  on('tool.call', async ($, e, next) => {
112    const ran = await next(e)
113    const path = (e as unknown as { file_path?: unknown }).file_path
114    if (!e.agentId && (e.tool === 'Write' || e.tool === 'Edit') && typeof path === 'string' && isShelved(path) && ran.deny === undefined && !ran.isError) written.add(path)
115    return ran
116  }).catch(($, e, next) => next(e))
117
118  on('turn.complete', async ($, e, next) => {
119    const result = await next(e)
120    if (e.agentId || e.reason !== 'answer') return result
121    const fromAnswer = await existing($, [e.answer, ...written].join('\n'))
122    written.clear()
123    await update($, claude, () => fromAnswer.slice(0, SHOWN))
124    return result
125  })
126
127  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
128    const images = visible(await read($, pasted), await read($, draft), await read($, sent)).slice(-SHOWN)
129    const paths = (await read($, mine)).slice(-SHOWN)
130    const given = (await read($, claude)).filter(entry => !paths.some(p => p.path === entry.path))
131    if (e.props.hasSurvey || (!images.length && !paths.length && !given.length)) return next(e)
132    const { Box, Text, Button } = $.ui.resolve(e)
133    const below = await next(e)
134    return (
135      <Box flexDirection="column">
136        {below}
137        <Box flexDirection="row" flexWrap="wrap" paddingX={1} columnGap={1}>
138          {(images.length > 0 || paths.length > 0) && <Box flexShrink={0}><Text dimColor>나</Text></Box>}
139          {images.map(shot => (
140            <Button key={`image-${shot.number}`} label={`Image #${shot.number}`} onPress={() => open($, shot.path)} />
141          ))}
142          {paths.map((entry, index) => (
143            <Button key={`mine-${index}`} label={labelOf(entry)} onPress={() => open($, entry.path)} />
144          ))}
145          {given.length > 0 && <Box flexShrink={0}><Text dimColor>Claude</Text></Box>}
146          {given.map((entry, index) => (
147            <Button key={`claude-${index}`} label={labelOf(entry)} onPress={() => open($, entry.path)} />
148          ))}
149        </Box>
150      </Box>
151    )
152  })
153}
154
hooks/paths.ts 53 lines
1import type { FsEntry } from 'claude-code'
2
3import type { Shot } from '../types'
4
5const EXT = [
6  'pdf|hwpx?|docx?|pptx?|xlsx?|od[tps]|rtf|pages|key|numbers|epub|html?|md|markdown|txt|csv|tsv|log',
7  'png|jpe?g|gif|webp|bmp|svg|heic|tiff?',
8  'mp4|mov|webm|mkv|mp3|wav|m4a|flac|zip',
9].join('|')
10
11export const isShelved = (path: string) => new RegExp(`\\.(?:${EXT})$`, 'i').test(path)
12
13const FILE_URL = /file:\/\/(\/[^\s"'`)>\]]+)/g
14const QUOTED = /["'`]([^"'`\n]*[\\/.][^"'`\n]*)["'`]/g
15const PART = '[\\w가-힣.@+\\-]+'
16const TOKEN = new RegExp(`(^|[\\s(<\\[\`"'*_])((?:~[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|\\/)?${PART}(?:[\\\\/]${PART})*[\\\\/]?)(?=$|[\\s)>\\],:;!?\`"'*_]|\\.(?:\\s|$))`, 'gm')
17const INLINE = /`([^`\s]+)`/g
18
19// 슬래시가 있거나 확장자가 붙은 것만 경로 후보 (1.5, v2 같은 단어 제외)
20const pathLike = (raw: string) => /[\\/]/.test(raw) || /[^.\s]\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(raw)
21
22export const isAbsolute = (raw: string) => raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)
23
24export function resolve(raw: string, cwd: string, home: string) {
25  if (raw === '~' || /^~[\\/]/.test(raw)) return `${home}${raw.slice(1)}`
26  if (isAbsolute(raw)) return raw
27  return `${cwd.replace(/[\\/]$/, '')}/${raw.replace(/^\.[\\/]/, '')}`
28}
29
30export function candidates(text: string) {
31  const found = [
32    ...[...text.matchAll(FILE_URL)].map(m => decodeURIComponent(m[1])),
33    ...[...text.matchAll(QUOTED)].map(m => m[1].trim().replace(/:\d+(?::\d+)?$/, '')),
34    ...[...text.matchAll(TOKEN)].map(m => m[2]),
35  ]
36  const inline = [...text.matchAll(INLINE)].map(m => m[1].replace(/:\d+(?::\d+)?$/, '')).filter(raw => /[A-Za-z가-힣]/.test(raw))
37  return [...new Set([...found.filter(pathLike), ...inline].filter(raw => !raw.includes('://')))]
38}
39
40export function pick(entries: readonly FsEntry[], dir: string): Shot[] {
41  return entries
42    .filter(entry => entry.kind === 'file' && /^\d+\.[a-z]+$/i.test(entry.name))
43    .map(entry => ({ path: `${dir}/${entry.name}`, number: Number.parseInt(entry.name, 10), mtimeMs: entry.mtimeMs }))
44    .sort((a, b) => a.number - b.number)
45}
46
47// Claude Code 내부 규약: <임시 루트>/claude-<uid, Windows는 0>/<프로젝트 폴더>/<세션 ID>/images/<번호>.<확장자>
48export const slug = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
49
50export const refsOf = (text: string) => [...new Set([...text.matchAll(/\[Image #(\d+)\]/g)].map(m => Number(m[1])))]
51
52export const visible = (shots: Shot[], draft: number[], sent: number[]) => shots.filter(shot => draft.includes(shot.number) || sent.includes(shot.number))
53
hooks/platform.ts 19 lines
1export type Os = 'mac' | 'windows' | 'linux'
2
3export type Env = Record<string, string | undefined>
4
5// Node os.tmpdir() 규칙: Claude Code가 붙여넣은 이미지를 두는 임시 루트
6export function tmpRoot(os: Os, env: Env) {
7  if (env.CLAUDE_CODE_TMPDIR) return env.CLAUDE_CODE_TMPDIR
8  if (os === 'windows') return env.TEMP || env.TMP || `${env.SystemRoot || env.windir || 'C:\\Windows'}\\temp`
9  return (env.TMPDIR || env.TMP || env.TEMP || '/tmp').replace(/(.)\/+$/, '$1')
10}
11
12export function openCommand(os: Os, path: string) {
13  if (os === 'mac') return ['open', path]
14  if (os === 'windows') return ['rundll32', 'url.dll,FileProtocolHandler', path.replace(/\//g, '\\')]
15  return ['xdg-open', path]
16}
17
18export const baseName = (path: string) => path.split(/[\\/]/).at(-1) ?? path
19
types/index.d.ts 10 lines
1export type Shot = { path: string; number: number; mtimeMs: number }
2
3export type Entry = { path: string; isDir: boolean }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'file-dock': { pasted: Shot[]; draft: number[]; sent: number[]; mine: Entry[]; claude: Entry[] }
8  }
9}
10