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

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 | 여는 명령 |
|---|---|
| Linux | xdg-open |
| macOS | open |
| Windows | rundll32 url.dll,FileProtocolHandler |
uname -s, id -u, 그리고 버튼을 눌렀을 때 위의 여는 명령<임시 폴더>/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로 녹화했습니다.
hooks/register.tsx 154 lines1import { 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}
154hooks/paths.ts 53 lines1import 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))
53hooks/platform.ts 19 lines1export 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
19types/index.d.ts 10 lines1export 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