Finder에서 복사한 파일을 입력창에 붙여넣으면 파일 이름이 @경로 멘션으로 바뀝니다. Cmd+Z로 입력 내용을 되돌릴 수도 있습니다. macOS 전용

<img alt="claude-code-mods 로고" src="./docs/images/logo.png" width="160">
<h1 align="center">claude-code-mods</h1>
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-111111?style=flat-square"></a> <a href="https://claude.com/claude-code"><img alt="Claude Code 2.1.299" src="https://img.shields.io/badge/Claude%20Code-2.1.299-111111?style=flat-square"></a> <a href="#설치"><img alt="marketplace oobg" src="https://img.shields.io/badge/marketplace-oobg-111111?style=flat-square"></a> <img alt="5 mods" src="https://img.shields.io/badge/mods-5-D97757?style=flat-square"> <a href="https://github.com/oobg/claude-code-mods/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/oobg/claude-code-mods?style=flat-square&color=111111"></a>
<a href="#주요-기능"><b>주요 기능</b></a> | <a href="#설치"><b>설치</b></a> | <a href="#모드-목록"><b>모드 목록</b></a> | <a href="#상세-문서"><b>상세 문서</b></a> | <a href="https://oobg.github.io/claude-code-mods/"><b>문서 사이트</b></a>
claude-code-mods는 Claude Code의 입력창과 도구 결과에 기능을 추가하는 모드 5개를 모은 저장소입니다. 입력창 위에서는 다음 요청을 제안하고 자주 쓰는 문구 프리셋을 표시하며, 입력창 아래에서는 세션 상태를 보여 줍니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름을 @경로 멘션으로 바꾸고, Cmd+Z로 입력 내용을 되돌릴 수 있습니다. 넓은 터미널에서는 Edit와 Write 결과의 diff를 좌우로 비교할 수 있습니다. 모드는 필요한 것만 골라 설치하면 되고, 따로 설치할 프로그램은 없습니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="next-prompts: 턴이 끝나면 다음에 보낼 만한 요청이 하나씩 나타난다" src="./docs/images/showcase/suggest-light.gif" width="100%"> <sub>턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다. <code>$1</code>을 입력하고 스페이스를 누르거나 번호를 클릭하면 문구가 입력창에 들어가고, 문구를 클릭하면 바로 보냅니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="quick-replies: 입력창에 $1을 치고 스페이스를 누르면 $1이 1번 프리셋 문구로 바뀐다" src="./docs/images/showcase/preset-light.gif" width="100%"> <sub>자주 쓰는 문구를 전역 또는 저장소별 프리셋으로 저장합니다. 입력창에 <code>$4</code>를 치고 스페이스를 누르면 해당 문구로 바뀝니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/dashboard.md"><picture> <img alt="dashboard: 컨텍스트 게이지가 차면서 색이 바뀐다" src="./docs/images/showcase/gauge-light.gif" width="100%"> <sub>모델, 컨텍스트, 사용 한도, 브랜치를 입력창 아래 두 줄에 표시합니다. 게이지가 찰수록 색이 노란색, 주황색, 빨간색 순으로 바뀝니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/key-assist.md"><picture> <img alt="key-assist: 붙여넣은 파일 이름이 @경로로 바뀐다" src="./docs/images/showcase/paste-light.gif" width="100%"> <sub>Finder에서 복사한 파일을 붙여넣으면 파일 이름이 <code>@경로</code>로 바뀝니다. Cmd+Z로 입력 내용을 되돌릴 수 있습니다</sub> </td> </tr> <tr> <td colspan="2" valign="top"> <a href="./docs/split-diff.md"><picture> <img alt="split-diff: 터미널 폭이 120칸 이상이면 좌우 diff, 미만이면 원래 세로 diff로 전환된다" src="./docs/images/showcase/split-diff-light.gif" width="100%"> <sub>터미널 폭이 120칸 이상이면 Edit와 Write 결과의 diff에서 원래 내용은 왼쪽에, 바뀐 내용은 오른쪽에 표시합니다. 바뀐 단어는 더 밝은 색으로 강조합니다</sub> </td> </tr> </table>
$2를 입력하고 스페이스를 누르면 2번 문구가 그 자리에 들어갑니다. $12처럼 두 자리 번호도 쓸 수 있습니다.@경로 멘션이 입력됩니다.이 저장소가 oobg 마켓플레이스입니다. 먼저 Claude Code 입력창에서 마켓플레이스를 등록합니다.
/plugin marketplace add oobg/claude-code-mods
그다음 필요한 모드만 골라 설치합니다.
/plugin install next-prompts@oobg
/plugin install quick-replies@oobg
/plugin install dashboard@oobg
/plugin install key-assist@oobg
/plugin install split-diff@oobg
설치한 모드는 Claude Code를 다시 시작하거나 /reload-plugins를 실행하면 적용됩니다. 설치 여부는 /plugin 화면에서 확인하거나 터미널에서 claude plugin list를 실행해 확인할 수 있습니다.
Claude Code를 시작하면 quick-replies의 기본 프리셋과 dashboard가 바로 표시됩니다. next-prompts의 제안은 첫 턴이 끝난 뒤에 나타납니다.
| 모드 | 기능 | 위치 |
|---|---|---|
| next-prompts | 턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다 | 입력창 위 |
| quick-replies | 자주 쓰는 문구를 전역 프리셋이나 저장소별 프리셋에 저장합니다 | 입력창 위 |
| dashboard | 모델, 컨텍스트, 사용 한도, 브랜치를 두 줄로 표시합니다 | 입력창 아래 |
| key-assist | 붙여넣은 파일 이름을 @경로로 바꾸고, Cmd+Z로 입력한 내용을 되돌립니다 | 입력창 |
| split-diff | 넓은 터미널에서 Edit와 Write 결과의 diff를 좌우로 나눠 표시합니다 | 도구 결과 |
next-prompts와 quick-replies를 함께 쓰면 입력창 위에 표시되는 두 목록의 번호가 이어집니다. 다음 제안은 $1부터, 프리셋은 그다음 번호부터 시작합니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="입력창에 $2를 치고 스페이스를 누르면 $2가 2번 문구로 바뀐다" src="./docs/images/showcase/substitute-light.gif" width="100%"> <sub>입력창에 <code>$2</code>를 치고 스페이스를 누르면 <code>$2</code>가 2번 문구로 바뀝니다. 문장 중간에서도 사용할 수 있습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="번호를 클릭하면 입력창 내용이 그 문구로 바뀐다" src="./docs/images/showcase/pick-light.gif" width="100%"> <sub>번호(<code>$2:</code>)를 클릭하면 입력창의 기존 내용을 모두 2번 문구로 바꿉니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="문구를 클릭하면 그 문구만 바로 보내진다" src="./docs/images/showcase/send-light.gif" width="100%"> <sub>문구를 클릭하면 해당 문구만 바로 보냅니다. 입력창에 적어 둔 내용은 그대로 남습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$e를 입력하면 프리셋 편집 화면이 열린다" src="./docs/images/showcase/editor-light.gif" width="100%"> <sub>입력창에 <code>$e</code>를 입력하면 프리셋 편집 화면이 열립니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$0을 입력하면 프리셋을 한 줄로 접고 다시 입력하면 펼친다" src="./docs/images/showcase/fold-light.gif" width="100%"> <sub>입력창에 <code>$0</code>을 입력하면 프리셋을 한 줄로 접고, 다시 입력하면 펼칩니다</sub> </td> <td width="50%"></td> </tr> </table>
입력창 맨 앞이나 공백 뒤에 $를 입력하고 번호와 스페이스를 쳐야 문구로 바뀌며, 바뀐 내용은 ctrl+_로 되돌릴 수 있습니다.
먼저 마켓플레이스 목록을 갱신합니다.
/plugin marketplace update oobg
그다음 /plugin 화면에서 설치한 모드를 업데이트하거나, 터미널에서 claude plugin update <모드>@oobg를 실행합니다. 업데이트한 모드는 Claude Code를 다시 시작하면 적용됩니다.
모드를 삭제하려면 다음 명령을 실행합니다.
/plugin uninstall next-prompts@oobg
/plugin uninstall quick-replies@oobg
/plugin uninstall dashboard@oobg
/plugin uninstall key-assist@oobg
/plugin uninstall split-diff@oobg
[!NOTE] key-assist를 삭제해도 키 설정에 추가된
cmd+z항목은 남습니다. 필요하지 않으면~/.claude/keybindings.json에서 직접 지웁니다.
| 증상 | 해결 |
|---|---|
| 설치 목록에는 있는데 모드가 표시되지 않습니다 | Claude Code를 다시 시작합니다 |
| next-prompts 제안이 나오지 않습니다 | 첫 턴이 끝나야 제안이 나옵니다. 답 본문이 120자 미만이면 제안하지 않습니다 |
| Cmd+Z가 동작하지 않습니다 | Command 키 입력을 전달하는 터미널인지 확인합니다. 아니면 ctrl+_를 씁니다 |
| Ctrl+Z를 눌렀더니 Claude Code가 멈췄습니다 | Ctrl+Z는 터미널에서 실행 중인 프로세스를 일시 중지합니다. fg를 입력하면 다시 실행됩니다 |
모드별 자세한 해결 방법은 각 상세 문서의 "문제 해결" 절에서 확인할 수 있습니다.
각 모드의 상세 문서는 문서 사이트에서도 볼 수 있습니다.
| 문서 | 내용 |
|---|---|
| next-prompts | 제안이 나오는 조건, 나타나는 방식, 모델 호출과 사용량 |
| quick-replies | 프리셋 범위와 번호, 접기와 자동 제목, 편집 화면 키, 저장 데이터 |
| dashboard | 항목 설명, 표시 형태와 색상, /usage 조회, 참고 사항 |
| key-assist | 파일 붙여넣기 동작, Cmd+Z 키 설정, 제한 사항 |
| split-diff | 좌우 표시 조건, 줄 맞추기, 단어 강조, 제한 사항 |
Copyright (c) 2026 oobg
이 프로젝트는 MIT 라이선스에 따라 배포됩니다.
hooks/register.ts 227 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// 이보다 긴 덩어리는 파일 이름일 리 없으니 클립보드를 읽지 않는다.
4const MAX_PASTE = 4000
5
6// macOS 클립보드에 든 파일들의 전체 경로를 한 줄에 하나씩 낸다. 파일이 아니면 빈 출력이다.
7const READ_FILES = [
8 'ObjC.import("AppKit");',
9 'const pb = $.NSPasteboard.generalPasteboard;',
10 'const urls = pb.readObjectsForClassesOptions($([$.NSURL]), $({ NSPasteboardURLReadingFileURLsOnlyKey: true }));',
11 'const out = [];',
12 'if (urls) for (let i = 0; i < urls.count; i++) out.push(ObjC.unwrap(urls.objectAtIndex(i).path));',
13 'out.join("\\n")',
14].join('\n')
15
16export async function clipboardFiles($: EngineInterface): Promise<string[]> {
17 try {
18 const { exitCode, stdout } = await $.process.run(['osascript', '-l', 'JavaScript', '-e', READ_FILES], { timeoutMs: 2000 })
19 if (exitCode !== 0) {
20 return []
21 }
22 return stdout
23 .split('\n')
24 // Finder가 준 경로의 한글은 자모로 풀린(NFD) 채일 때가 있다. 입력창에 붙는 이름은 완성형(NFC)이라 맞춰 둔다.
25 .map(line => line.trim().normalize('NFC'))
26 .filter(line => line.startsWith('/'))
27 } catch {
28 return []
29 }
30}
31
32// 공백이 든 경로는 따옴표로 감싸야 멘션 하나로 읽힌다.
33export function mention(path: string): string {
34 return /\s/.test(path) ? `@"${path}"` : `@${path}`
35}
36
37// before에 글자만 끼워 넣어 after가 됐으면 그 자리와 덩어리를, 아니면 undefined를 낸다.
38export function insertion(before: string, after: string): { start: number; text: string } | undefined {
39 if (after.length <= before.length) {
40 return undefined
41 }
42 let head = 0
43 while (head < before.length && before[head] === after[head]) {
44 head++
45 }
46 let tail = 0
47 while (tail < before.length - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) {
48 tail++
49 }
50 if (head + tail !== before.length) {
51 return undefined
52 }
53 return { start: head, text: after.slice(head, after.length - tail) }
54}
55
56const squash = (text: string) => text.normalize('NFC').trim().replace(/\s+/g, ' ')
57const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
58
59// 붙여넣은 덩어리가 클립보드 파일들의 이름(또는 경로)과 같으면 @경로로 바꾼 덩어리를 낸다. 앞뒤 공백은 그대로 둔다.
60export function asMentions(text: string, paths: readonly string[]): string | undefined {
61 if (paths.length === 0) {
62 return undefined
63 }
64 const pasted = squash(text)
65 if (pasted !== squash(paths.map(basename).join(' ')) && pasted !== squash(paths.join(' '))) {
66 return undefined
67 }
68 const lead = /^\s*/.exec(text)?.[0] ?? ''
69 const trail = /\s*$/.exec(text)?.[0] ?? ''
70 return lead + paths.map(mention).join(' ') + trail
71}
72
73type Rewrite = { text: string; start: number; end: number; delta: number }
74
75// before에서 after로 바뀐 것이 파일 붙여넣기였으면 @경로로 바꾼 입력창을 낸다.
76async function rewrite($: EngineInterface, before: string, after: string): Promise<Rewrite | undefined> {
77 const pasted = insertion(before, after)
78 if (pasted === undefined || pasted.text.trim() === '' || pasted.text.length > MAX_PASTE) {
79 return undefined
80 }
81 const replaced = asMentions(pasted.text, await clipboardFiles($))
82 if (replaced === undefined) {
83 return undefined
84 }
85 const end = pasted.start + pasted.text.length
86 return {
87 text: after.slice(0, pasted.start) + replaced + after.slice(end),
88 start: pasted.start,
89 end,
90 delta: replaced.length - pasted.text.length,
91 }
92}
93
94// Cmd+Z 되돌리기. 모드는 Command 키를 받지 못해(Cmd+Z가 그냥 z로 온다) 직접 되돌릴 수 없다.
95// 대신 엔진의 되돌리기(chat:undo)에 cmd+z를 잇는 줄을 키 설정 파일에 한 번 넣는다.
96const UNDO_KEY = 'cmd+z'
97const UNDO_ACTION = 'chat:undo'
98const UNDO_DONE = 'undo-key-done'
99
100type KeyBlock = { context?: unknown; bindings?: unknown }
101
102// cmd+z와 같은 키를 가리키는 다른 표기(command+z, super+z, win+z)도 이미 정한 것으로 본다.
103const isUndoKey = (key: string) => key.toLowerCase().replace(/\b(command|super|win)\+/g, 'cmd+') === UNDO_KEY
104
105// 키 설정 파일 글에 cmd+z 되돌리기를 더한 글을 낸다. 파일이 없으면 새로 짓는다.
106// 사람이 cmd+z를 이미 어느 맥락에든 정해 뒀거나(null로 끈 것 포함) 파일을 읽을 수 없으면 undefined를 낸다.
107export function withUndoKey(source: string | undefined): string | undefined {
108 let file: unknown = {
109 $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
110 $docs: 'https://code.claude.com/docs/en/keybindings',
111 bindings: [],
112 }
113 if (source !== undefined && source.trim() !== '') {
114 try {
115 file = JSON.parse(source)
116 } catch {
117 return undefined
118 }
119 }
120 if (typeof file !== 'object' || file === null || !Array.isArray((file as { bindings?: unknown }).bindings)) {
121 return undefined
122 }
123 const blocks = (file as { bindings: KeyBlock[] }).bindings
124 const maps = blocks.map(block => (typeof block?.bindings === 'object' && block.bindings !== null ? (block.bindings as Record<string, unknown>) : undefined))
125 if (maps.some(map => map !== undefined && Object.keys(map).some(isUndoKey))) {
126 return undefined
127 }
128 const chat = blocks.findIndex((block, i) => block?.context === 'Chat' && maps[i] !== undefined)
129 if (chat >= 0) {
130 maps[chat]![UNDO_KEY] = UNDO_ACTION
131 } else {
132 blocks.push({ context: 'Chat', bindings: { [UNDO_KEY]: UNDO_ACTION } })
133 }
134 return JSON.stringify(file, null, 2) + '\n'
135}
136
137// 설치한 뒤 처음 연 세션에서 한 번만 한다. 사람이 나중에 그 줄을 지우면 다시 넣지 않는다.
138async function enableUndoKey($: EngineInterface): Promise<void> {
139 if ((await $.store.get(UNDO_DONE)) === true) {
140 return
141 }
142 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
143 const path = `${dir}/keybindings.json`
144 let source: string | undefined
145 try {
146 source = await $.fs.read(path)
147 } catch {
148 // 없는 파일이면 새로 짓고, 있는데 못 읽었으면 덮어쓰지 않고 다음 세션에 다시 본다.
149 if ((await $.fs.stat(path).catch(() => undefined)) !== undefined) {
150 return
151 }
152 }
153 const updated = withUndoKey(source)
154 if (updated !== undefined) {
155 const { exitCode } = await $.process.run(
156 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat > "$1.key-assist" && mv "$1.key-assist" "$1"', 'sh', path],
157 { stdin: updated },
158 )
159 if (exitCode !== 0) {
160 return
161 }
162 $.ui.toast('key-assist: Cmd+Z로 입력한 내용을 되돌릴 수 있게 키 설정에 한 줄 추가했어요')
163 }
164 await $.store.set(UNDO_DONE, true)
165}
166
167// 모드가 마지막으로 본 입력창 글. 붙여넣기는 prompt.edit를 거치지 않으니, 다음 이벤트의 글이 이것과 다르면 그 사이 무언가 붙은 것이다.
168// 엔진이 보여 주는 글을 알려 주는 이벤트(prompt.edit, prompt.fill, prompt.submit)에서만 나아간다.
169let seen = ''
170
171export const register: Register = on => {
172 on('session.start', async ($, e, next) => {
173 const result = await next(e)
174 seen = (await $.prompt.read()).text
175 void enableUndoKey($).catch(() => {})
176 return result
177 })
178
179 // 붙여넣은 직후 자동완성을 물어 오면 그 자리에서 바꾼다.
180 on('prompt.autocomplete', async ($, e, next) => {
181 if (e.text !== seen) {
182 const result = await rewrite($, seen, e.text)
183 if (result !== undefined) {
184 seen = result.text
185 void $.prompt.fill({ text: result.text })
186 }
187 }
188 return next(e)
189 })
190
191 // 붙여넣은 뒤 첫 키가 오면, 그 키를 적용하기 전에 바꾼다. 붙여넣은 덩어리 뒤의 자리는 늘어난 만큼 민다.
192 on('prompt.edit', async ($, e, next) => {
193 if (e.text !== seen) {
194 const result = await rewrite($, seen, e.text)
195 if (result !== undefined) {
196 const shift = (at: number) => (at >= result.end ? at + result.delta : at > result.start ? result.end + result.delta : at)
197 const box = await next({ ...e, text: result.text, cursor: shift(e.cursor), start: shift(e.start), end: shift(e.end) })
198 seen = box.text
199 return box
200 }
201 }
202 const box = await next(e)
203 seen = box.text
204 return box
205 })
206
207 // 다른 모드가 채운 글은 붙여넣기가 아니다.
208 on('prompt.fill', async ($, e, next) => {
209 const result = await next(e)
210 if (result.isFilled) {
211 seen = e.mode === 'replace' ? e.text : (await $.prompt.read()).text
212 }
213 return result
214 })
215
216 // 붙여넣고 바로 Enter를 치면 보내는 글을 바꾼다. 슬래시 명령의 줄은 바꿀 수 없다.
217 on('prompt.submit', async ($, e, next) => {
218 const before = seen
219 seen = ''
220 if (e.origin.kind !== 'composer' || e.command !== undefined || e.text === before) {
221 return next(e)
222 }
223 const result = await rewrite($, before, e.text)
224 return next(result === undefined ? e : { ...e, text: result.text })
225 })
226}
227