k-mods 카탈로그를 한 패널에서 보고 설치·제거하는 모드 상점. /k-mods 하나면 충분해요.

k-mods 카탈로그를 패널 하나로 둘러보고, 키 하나로 설치·제거해요. /plugin install <이름>@k-mods를 외울 필요가 없어요.
╭────────────────────────────────────────────────────────────────────────────────────────────────✕─╮
│ k-mods 모드 상점 │
│ │
│ 0: 전체 (10) 1: 한국어화 (1) 2: 테마 (1) 3: 애니메이션 (1) 4: 상태줄 (2) 5: 도구 (1) │
│ 6: 자동화 (1) 7: 지킴이 (2) 8: 연동 (0) 9: 묶음 (1) │
│ │
│ 오리지널 한글 상태표시줄 │
│ 프롬프트 아래 상태줄을 한국어로 보여줘요. │
│ 화면만 [ 설치 ] 자세히 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
/plugin marketplace add SeongGwangJu/k-mods
/plugin install mod-store@k-mods
/k-mods를 입력하면 패널이 열려요(이미 열려 있으면 닫혀요). 작업 중에도 열 수 있어요.
설치 버튼을 누르면 설치가 시작돼요. 이미 설치된 항목은 설치됨 · 제거로 바뀌고, 누르면 정말 지울지 한 번 물어봐요.자세히를 누르면 제작자·라이선스·명령·준비물·주의사항·홈페이지가 펼쳐져요.지금 적용 버튼으로 /reload-plugins를 바로 실행할 수 있어요.따로 설정할 값은 없어요.
/k-mods 명령은 immediate: true로 등록해서, Claude가 작업 중에도 바로 열려요.$.ui.resolve로 받은 Box/Text/Button/Link만으로 그려요. 폭이 100칸 이상이면 넓음, 70~99칸이면 보통, 그 아래는 좁음으로 보고, 좁을수록 요약 문장을 줄이고 권한은 가장 민감한 것 하나만 보여줘요(실제 세션에서 확인: e.props.bodyColumns는 터미널 전체 폭보다 테두리만큼 좁아서, 70칸짜리 터미널은 "좁음" 단계로 그려져요)./plugin install <이름>@k-mods를 세션 안에서 흉내 내는 방법($.command.run({ command: 'plugin', args: 'install ...' }))은 설치를 바로 하지 않고 /plugin 패널의 Discover 탭을 여는 것으로 끝나요(공식 문서로 확인, 아래 "한계" 참고). 그래서 이 mod는 $.process.run(['claude', 'plugin', 'install', '<이름>@k-mods', '--scope', 'user'])로 셸의 claude 명령을 직접 실행해요. 누르기 전에 claude --version으로 2.1.287 이상인지 먼저 확인하고, 아니거나 명령이 없으면 복사할 수 있는 명령과 함께 한국어로 알려줘요. 설치 중 "마켓플레이스가 없다"는 응답을 받으면 /plugin marketplace add SeongGwangJu/k-mods를 복사 버튼과 함께 안내해요.$.settings.read()의 enabledPlugins["<이름>@k-mods"]가 true인지로 판단하고, 패널을 열 때와 설치·제거 직후에 다시 읽어요.k-mods 설치 완료 · /k-mods 로 모드를 둘러보세요 토스트를 보여줘요($.store에 welcomed 표시를 남겨서 다음부터는 안 떠요).claude --version, claude plugin install ..., claude plugin uninstall ...만 실행해요. 그 외 어떤 프로그램도 실행하지 않아요.$.settings.read()로 enabledPlugins만 읽어요(쓰지 않아요).welcomed 표시 하나만 $.store에 저장해요.claude plugin install이 알아서 하는 몫이에요(마켓플레이스 메타데이터 내려받기).claude plugin install 자체는 "마켓플레이스를 찾을 수 없다"로 끝나는 걸 실제 세션에서 확인했어요(검증 방법은 README 하단의 참고 사항 대신 PR 설명에 적어 둘게요). 마켓플레이스를 추가한 뒤에는 그대로 동작해요./plugin install <이름>@k-mods를 세션 안에서 대신 실행하는 방법은 쓰지 않아요. 패널만 열고 설치는 안 해서, 한 번에 설치된다는 기대를 깨기 때문이에요.$.command.register로 등록한 /k-mods라는 이름을 다른 mod가 먼저 차지하면 조용히 넘어가요(패널을 열 방법이 없어진다는 뜻이라, 이 경우 claude plugin list로 충돌하는 mod를 확인해 주세요).hooks/catalog.ts)는 지금은 손으로 채운 값이고, 나중에 scripts/build.mjs가 registry/*.json으로부터 다시 만들어요.오리지널. hooks/catalog.ts의 카탈로그 항목 설명은 각 mod의 registry/<name>.json을 요약한 것이고, 원본 출처·라이선스는 각 mod 자신의 README에 있어요.
테스트한 Claude Code 버전: 2.1.291
hooks/register.ts 265 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { CATALOG, SELF_NAME } from './catalog'
4import {
5 type Banner,
6 MARKETPLACE_ADD_COMMAND,
7 MIN_CLAUDE_VERSION,
8 errorBannerOf,
9 firstVersionToken,
10 installArgsFor,
11 installedNamesFrom,
12 isVersionAtLeast,
13 messageOf,
14 parseInstallOutcome,
15 parseUninstallOutcome,
16 uninstallArgsFor,
17 visibleCatalog,
18 widthTierOf,
19} from './lib'
20import { paneView, type StoreActions, type StoreModel } from './view'
21
22const PANE_ID = 'k-mods-store'
23const PANE_TITLE = 'k-mods 모드 상점'
24const COMMAND_NAME = 'k-mods'
25
26// ---------------------------------------------------------------------------
27// 모듈 상태. 모듈이 새로 로드(핫 리로드)될 때까지만 유지된다 — CONTRIBUTING.md의
28// "모듈 전역 변수" 수명과 같다. $.state를 쓰지 않는 이유: 패널을 쓰는 동안만
29// 의미 있는 값들이라 세션을 넘어 지킬 필요가 없다.
30// ---------------------------------------------------------------------------
31
32let tab: StoreModel['tab'] = 'all'
33let expanded: string | null = null
34let installed = new Set<string>()
35let busy = new Set<string>()
36let banner: Banner = { kind: 'none' }
37let justInstalled: string | null = null
38
39const CATALOG_VISIBLE = visibleCatalog(CATALOG, SELF_NAME)
40
41// ---------------------------------------------------------------------------
42// $를 받는 도우미. 모두 이 파일(register.ts) 최상위에 선언했다 — mods API는
43// 같은 파일 최상위 함수에만 넘길 수 있고, 클로저·다른 파일의 함수는 안 된다.
44// ---------------------------------------------------------------------------
45
46function redraw($: EngineInterface): void {
47 $.ui.invalidate('ui.render')
48}
49
50/** PATH의 claude가 mods를 지원하는 2.1.287 이상인지 확인한다. */
51async function checkCliVersion(
52 $: EngineInterface,
53): Promise<{ ok: true } | { ok: false; message: string }> {
54 try {
55 const result = await $.process.run(['claude', '--version'], { timeoutMs: 10_000 })
56 const version = firstVersionToken(result.stdout)
57 if (version === null) {
58 return { ok: false, message: 'PATH의 claude 명령에서 버전을 읽지 못했어요.' }
59 }
60 if (!isVersionAtLeast(version, MIN_CLAUDE_VERSION)) {
61 return {
62 ok: false,
63 message: `PATH의 claude(${version})가 너무 오래됐어요. ${MIN_CLAUDE_VERSION} 이상이 필요해요.`,
64 }
65 }
66 return { ok: true }
67 } catch (error) {
68 return { ok: false, message: `claude 명령을 실행하지 못했어요: ${messageOf(error)}` }
69 }
70}
71
72/** enabledPlugins에서 설치 상태를 다시 읽는다. 패널을 열 때, 설치·제거 뒤에 부른다. */
73async function refreshInstalled($: EngineInterface): Promise<void> {
74 try {
75 const settings = await $.settings.read()
76 installed = installedNamesFrom(settings, CATALOG)
77 } catch {
78 // 설정을 못 읽으면 이전 상태를 그대로 둔다
79 }
80}
81
82async function installMod($: EngineInterface, name: string): Promise<void> {
83 if (busy.has(name)) return
84 busy = new Set(busy).add(name)
85 banner = { kind: 'none' }
86 redraw($)
87
88 try {
89 const version = await checkCliVersion($)
90 if (!version.ok) {
91 banner = errorBannerOf(installArgsFor(name), version.message)
92 return
93 }
94
95 const argv = installArgsFor(name)
96 const result = await $.process.run(argv, { timeoutMs: 60_000 })
97 const outcome = parseInstallOutcome(result)
98
99 if (outcome.kind === 'marketplace-missing') {
100 banner = { kind: 'no-marketplace', addCommand: MARKETPLACE_ADD_COMMAND }
101 } else if (outcome.kind === 'failed') {
102 banner = errorBannerOf(argv, outcome.message)
103 } else {
104 installed = new Set(installed).add(name)
105 justInstalled = name
106 $.ui.toast('설치했어요')
107 }
108 } catch (error) {
109 banner = errorBannerOf(installArgsFor(name), messageOf(error))
110 } finally {
111 const nextBusy = new Set(busy)
112 nextBusy.delete(name)
113 busy = nextBusy
114 redraw($)
115 }
116}
117
118async function uninstallMod($: EngineInterface, name: string): Promise<void> {
119 if (busy.has(name)) return
120 busy = new Set(busy).add(name)
121 banner = { kind: 'none' }
122 redraw($)
123
124 try {
125 const version = await checkCliVersion($)
126 if (!version.ok) {
127 banner = errorBannerOf(uninstallArgsFor(name), version.message)
128 return
129 }
130
131 const argv = uninstallArgsFor(name)
132 const result = await $.process.run(argv, { timeoutMs: 60_000 })
133 const outcome = parseUninstallOutcome(result)
134
135 if (outcome.kind === 'failed') {
136 banner = errorBannerOf(argv, outcome.message)
137 } else {
138 const nextInstalled = new Set(installed)
139 nextInstalled.delete(name)
140 installed = nextInstalled
141 if (justInstalled === name) justInstalled = null
142 $.ui.toast('제거했어요')
143 }
144 } catch (error) {
145 banner = errorBannerOf(uninstallArgsFor(name), messageOf(error))
146 } finally {
147 const nextBusy = new Set(busy)
148 nextBusy.delete(name)
149 busy = nextBusy
150 redraw($)
151 }
152}
153
154/** 제거는 되돌리기 성가신 일이라 $.ui.ask로 한 번 확인한다. */
155async function confirmAndUninstall($: EngineInterface, name: string): Promise<void> {
156 let answer = '취소'
157 try {
158 answer = await $.ui.ask(`${name} 모드를 제거할까요?`, ['제거', '취소'])
159 } catch {
160 return
161 }
162 if (answer !== '제거') return
163 await uninstallMod($, name)
164}
165
166async function applyReload($: EngineInterface): Promise<void> {
167 try {
168 await $.command.run({ command: 'reload-plugins', args: '' })
169 justInstalled = null
170 redraw($)
171 } catch {
172 $.ui.toast('자동 적용에 실패했어요. /reload-plugins 를 입력해 주세요.')
173 }
174}
175
176async function copyToClipboard(
177 $: EngineInterface,
178 text: string,
179 surface: Parameters<EngineInterface['ui']['copy']>[0]['surface'],
180): Promise<void> {
181 try {
182 const result = await $.ui.copy({ text, surface })
183 if (!result.isCopied) $.ui.toast('복사하지 못했어요. 직접 입력해 주세요.')
184 } catch {
185 $.ui.toast('복사하지 못했어요. 직접 입력해 주세요.')
186 }
187}
188
189// ---------------------------------------------------------------------------
190// register
191// ---------------------------------------------------------------------------
192
193export function register(on: On) {
194 on('session.start', async ($, e, next) => {
195 try {
196 await $.command.register({
197 name: COMMAND_NAME,
198 description: 'k-mods 모드 상점을 열어요',
199 immediate: true,
200 })
201 } catch {
202 // 이름이 이미 다른 곳에서 쓰이고 있으면 조용히 넘어간다
203 }
204
205 try {
206 const welcomed = await $.store.get('welcomed')
207 if (welcomed !== true) {
208 $.ui.toast('k-mods 설치 완료 · /k-mods 로 모드를 둘러보세요')
209 await $.store.set('welcomed', true)
210 }
211 } catch {
212 // 저장소를 못 읽어도 세션은 그대로 시작한다
213 }
214
215 return next(e)
216 })
217
218 on('command.run', { command: COMMAND_NAME }, async $ => {
219 const panes = await $.ui.panes()
220 const isOpen = panes.some(pane => pane.id === PANE_ID)
221
222 if (isOpen) {
223 await $.ui.close({ id: PANE_ID })
224 return {}
225 }
226
227 await refreshInstalled($)
228 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true, closeOnEscape: true })
229 return {}
230 })
231
232 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
233 if (e.requestId !== PANE_ID) return next(e)
234
235 const { Box, Text, Button, Link } = $.ui.resolve(e)
236 const tier = widthTierOf(e.props.bodyColumns)
237
238 const model: StoreModel = { tab, expanded, installed, busy, banner, justInstalled, tier }
239
240 const actions: StoreActions = {
241 setTab: pickedTab => {
242 tab = pickedTab
243 redraw($)
244 },
245 toggleDetail: name => {
246 expanded = expanded === name ? null : name
247 redraw($)
248 },
249 // install/uninstall/applyReload/copyCommand는 Promise를 그대로 돌려준다:
250 // 엔진과 테스트 둘 다 ui.press가 onPress의 반환값을 기다려 주므로, 눌렀을 때
251 // 비동기 작업이 끝난 뒤의 상태를 바로 이어서 확인할 수 있다.
252 install: name => installMod($, name),
253 uninstall: name => confirmAndUninstall($, name),
254 applyReload: () => applyReload($),
255 copyCommand: (text, surface) => copyToClipboard($, text, surface),
256 dismissBanner: () => {
257 banner = { kind: 'none' }
258 redraw($)
259 },
260 }
261
262 return paneView({ Box, Text, Button, Link }, CATALOG_VISIBLE, model, actions)
263 })
264}
265hooks/catalog.ts 1602 lines1// 생성 파일: node scripts/build.mjs 가 registry/*.json 으로 만든다. 직접 고치지 말 것.
2
3export type CategoryId = 'korean' | 'look' | 'fun' | 'meter' | 'safety' | 'workflow' | 'integration' | 'bundle'
4export type Kind = 'upstream' | 'patched' | 'original' | 'bundle'
5export type CatalogEntry = {
6 name: string
7 displayName: string
8 summary: string
9 category: CategoryId
10 kind: Kind
11 author: string
12 license: string
13 homepage?: string
14 commands: string[]
15 requires: string[]
16 notes: string[]
17 permissions: string[]
18 featured?: boolean
19 dependencies?: string[]
20}
21
22export const MARKETPLACE = 'k-mods'
23export const CATALOG_VERSION = '0.2.0'
24
25// 상점 자신은 목록에서 숨긴다
26export const SELF_NAME = 'mod-store'
27
28// 권한 라벨, 민감한 것부터
29export const PERMISSION_ORDER: readonly string[] = ["네트워크","프로그램 실행","파일 쓰기","모델 호출","도구 호출 제어","프롬프트 입력","대화 읽기","환경·설정 읽기","파일 읽기","세션 메시지","소리","화면만"]
30
31export const CATEGORIES: { id: CategoryId; label: string }[] = [
32 {
33 "id": "localize",
34 "label": "한국어화"
35 },
36 {
37 "id": "theme",
38 "label": "테마"
39 },
40 {
41 "id": "animation",
42 "label": "애니메이션"
43 },
44 {
45 "id": "statusline",
46 "label": "상태줄"
47 },
48 {
49 "id": "tool",
50 "label": "도구"
51 },
52 {
53 "id": "automation",
54 "label": "자동화"
55 },
56 {
57 "id": "guard",
58 "label": "지킴이"
59 },
60 {
61 "id": "integration",
62 "label": "연동"
63 },
64 {
65 "id": "bundle",
66 "label": "묶음"
67 }
68]
69
70export const CATALOG: CatalogEntry[] = [
71 {
72 "name": "ko-ui",
73 "displayName": "한국어 UI 번역 사전",
74 "summary": "슬래시 커맨드 설명, `/config` 항목, 작업 표시줄 같은 화면 문구를 번역 사전에 맞춰 한국어로 표시해요.",
75 "category": "localize",
76 "kind": "upstream",
77 "author": "moduvoice",
78 "license": "MIT",
79 "homepage": "https://github.com/moduvoice/claude-code-ko-ui",
80 "commands": [
81 "/ko-dump"
82 ],
83 "requires": [],
84 "notes": [
85 "Claude Code 2.1.287 이상이 필요해요. 2.1.285에서는 `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` 환경 변수를 켜야 로드돼요.",
86 "모델 호출·네트워크 요청이 전혀 없는 고정 사전(dict.json) 치환 방식이라 토큰을 쓰지 않아요.",
87 "Spinner 단어 번역은 terminal 화면에서만 적용돼요(desktop은 원문 유지, 나머지 번역은 desktop에서도 적용돼요).",
88 "번역 안 된 새 문구는 `/ko-dump`로 확인해 `dict.json`에 직접 추가할 수 있어요(최대 200개까지 기록).",
89 "권한 확인 창, 환영 화면, 선택지 값, 에러 메시지, 프롬프트 아래 알약 문구는 훅이 없거나 번역하면 다른 정보가 사라져서 원문 그대로예요."
90 ],
91 "permissions": [
92 "대화 읽기",
93 "파일 읽기"
94 ],
95 "featured": true
96 },
97 {
98 "name": "skins",
99 "displayName": "스킨 (한국 수정판)",
100 "summary": "도구 호출·표·코드·셸 출력을 테마 카드 형식으로 표시해요. 원본에서 한글 표가 잘리던 문제를 고쳤어요",
101 "category": "theme",
102 "kind": "patched",
103 "author": "hellosverre",
104 "license": "MIT",
105 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/skins",
106 "commands": [
107 "/skin"
108 ],
109 "requires": [],
110 "notes": [],
111 "permissions": [
112 "도구 호출 제어",
113 "대화 읽기",
114 "환경·설정 읽기"
115 ],
116 "featured": true
117 },
118 {
119 "name": "gfm-render",
120 "displayName": "GFM 렌더러",
121 "summary": "대화 기록에 있는 GFM 경고문·체크리스트·취소선·Mermaid 다이어그램을 화면에 표시해요.",
122 "category": "theme",
123 "kind": "upstream",
124 "author": "briangtn",
125 "license": "MIT",
126 "homepage": "https://github.com/briangtn/claude-gfm-render",
127 "commands": [],
128 "requires": [],
129 "notes": [],
130 "permissions": [
131 "프로그램 실행",
132 "대화 읽기"
133 ]
134 },
135 {
136 "name": "prismantis",
137 "displayName": "프리즈만티스",
138 "summary": "표·코드·다이어그램·도구 호출 결과를 15가지 테마로 표시하고 복사 버튼을 추가해요.",
139 "category": "theme",
140 "kind": "upstream",
141 "author": "Nahum Litvin",
142 "license": "MIT",
143 "homepage": "https://github.com/NahumLitvin/prismantis",
144 "commands": [
145 "/prismantis"
146 ],
147 "requires": [],
148 "notes": [],
149 "permissions": [
150 "대화 읽기",
151 "환경·설정 읽기"
152 ]
153 },
154 {
155 "name": "spinner",
156 "displayName": "픽셀 장면 스피너 (한국 수정판)",
157 "summary": "Claude가 일하는 동안 입력창 위에 냥캣·Clawd 같은 픽셀 장면을 표시하고 펫을 키워요. 한국어·간결한 기본값으로 고친 수정판이에요",
158 "category": "animation",
159 "kind": "patched",
160 "author": "hoobnn",
161 "license": "MIT",
162 "homepage": "https://github.com/hoobnn/hoobnn-agent-mods",
163 "commands": [
164 "/spinner"
165 ],
166 "requires": [
167 "swiftc/Xcode Command Line Tools (오디오 테마 전용, macOS 14.2+ 및 '시스템 오디오 녹음' 권한 필요)"
168 ],
169 "notes": [
170 "원본과 다른 기본값: 한국어, 펫 줄 꺼짐, 하단 버튼 꺼짐, 스피너 줄 앞 마스코트 없음, random은 nyan·clawd·thunder·chomp 중 턴마다. 자세한 내용은 mod 설명서의 \"원본과 다른 점\"에 있어요.",
171 "`theme`를 audio로 바꾸면 첫 실행 시 `swiftc`로 `audio-tap.swift`를 로컬 컴파일해 실행하고, macOS Core Audio로 시스템 출력 소리의 레벨만 읽어 보여줘요(macOS 14.2+, 터미널에 '시스템 오디오 녹음' 권한 허용 필요). 저장·전송은 하지 않지만 Xcode Command Line Tools가 필요해요.",
172 "터미널이 느리거나 저전력 환경이면 `reducedMotion`을 켜서 마스코트·밴드·펫을 정지 이미지로 바꿀 수 있어요.",
173 "같은 작성자(hoobnn)의 `hud` 모드를 함께 쓰면 펫을 hud 쪽에 표시하고 쓰다듬은 횟수를 공유해요. `hud`가 없어도 정상 동작해요(안전한 no-op).",
174 "`language: ko`는 `/spinner` 응답과 레벨업·피날레 문구까지 전부 한국어로 번역되어 있어요(자리표시자 아님)."
175 ],
176 "permissions": [
177 "프로그램 실행",
178 "도구 호출 제어",
179 "대화 읽기",
180 "환경·설정 읽기",
181 "파일 읽기"
182 ],
183 "featured": true
184 },
185 {
186 "name": "cc-arcade",
187 "displayName": "CC 아케이드",
188 "summary": "입력창 위에서 스네이크·테트리스·2048·지뢰찾기 등 9가지 미니게임을 즐길 수 있어요. Claude가 일하는 동안 자라는 펫도 키울 수 있어요.",
189 "category": "animation",
190 "kind": "upstream",
191 "author": "Seza Akgün",
192 "license": "MIT",
193 "homepage": "https://github.com/sezaakgun/cc-arcade",
194 "commands": [
195 "/arcade"
196 ],
197 "requires": [
198 "대화형 터미널(비대화형 `-p` 모드에서는 동작하지 않아요)",
199 "Claude Code 2.1.287 미만이면 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 환경 변수 필요"
200 ],
201 "notes": [
202 "장르를 본뜬 클론이며 Tetris Holding·Taito·Atari·id Software와 무관해요(업스트림 설명 그대로).",
203 "최고 점수와 펫 상태는 로컬 $.store에만 저장돼요.",
204 "`/arcade colorblind`로 Doom을 빨강·초록 구분 없는 팔레트로 바꿀 수 있어요."
205 ],
206 "permissions": [
207 "도구 호출 제어",
208 "대화 읽기"
209 ]
210 },
211 {
212 "name": "clawd-spinner",
213 "displayName": "Clawd 스피너",
214 "summary": "189가지 스피너 단어마다 Clawd가 요리·춤·서성거리기 등 서로 다른 몸짓을 해요.",
215 "category": "animation",
216 "kind": "upstream",
217 "author": "Sai Rudra",
218 "license": "MIT",
219 "homepage": "https://github.com/saiharsha03/clawd-spinner",
220 "commands": [
221 "/clawd-talk"
222 ],
223 "requires": [],
224 "notes": [
225 "좁은 터미널(50칸 미만)에서는 자동으로 숨고 기본 스피너만 보여요.",
226 "`/clawd-talk`로 말풍선을 껐다 켤 수 있고, 설정은 세션 간 기억돼요."
227 ],
228 "permissions": [
229 "대화 읽기"
230 ]
231 },
232 {
233 "name": "clawd-tales",
234 "displayName": "Clawd 이야기",
235 "summary": "입력창 위에서 픽셀 Clawd가 Claude의 모든 도구 호출에 맞춰 몸짓을 해요. 서브에이전트마다 작은 동료도 등장해요.",
236 "category": "animation",
237 "kind": "upstream",
238 "author": "plaxagoras",
239 "license": "MIT",
240 "homepage": "https://github.com/plaxagoras/clawd-tales",
241 "commands": [
242 "/tales"
243 ],
244 "requires": [],
245 "notes": [
246 "히어로의 걷기·환호 스프라이트 원형과 기본 팔레트는 Claude Fables(henrik-thevibe, MIT)에서 가져왔다고 NOTICE 파일에 명시돼 있어요.",
247 "서브에이전트가 뜨면 작은 동료가 따로 등장하고, 모델 색깔에 따라 외형이 달라져요.",
248 "mobile 화면에서는 그려지지 않도록 코드로 명시돼 있어요."
249 ],
250 "permissions": [
251 "도구 호출 제어",
252 "대화 읽기"
253 ]
254 },
255 {
256 "name": "combo-meter",
257 "displayName": "콤보 미터",
258 "summary": "도구 호출이 성공하면 콤보 수치와 랭크가 올라가고 실패하면 콤보가 끊겨요. 랭크는 D부터 SSS까지예요.",
259 "category": "animation",
260 "kind": "upstream",
261 "author": "Sarthak Bhatore",
262 "license": "MIT",
263 "homepage": "https://github.com/sarthak2511/claude-combo",
264 "commands": [
265 "/combo"
266 ],
267 "requires": [],
268 "notes": [],
269 "permissions": [
270 "도구 호출 제어",
271 "대화 읽기",
272 "소리"
273 ]
274 },
275 {
276 "name": "diff-invaders",
277 "displayName": "디프 인베이더",
278 "summary": "Claude가 방금 쓴 diff 줄에 대응하는 외계인 편대를 표시하는 스페이스 인베이더예요. 토큰 소모 없이 입력창 위에서 플레이해요",
279 "category": "animation",
280 "kind": "upstream",
281 "author": "claude-code-templates",
282 "license": "MIT",
283 "homepage": "https://github.com/davila7/claude-code-templates/tree/375af9018a40e330e81542f59054daaa088c21aa/cli-tool/components/mods/games/diff-invaders",
284 "commands": [
285 "/diff-invaders"
286 ],
287 "requires": [],
288 "notes": [
289 "마우스 없이 방향키(←→/ad)와 스페이스만으로 플레이 가능해요(클릭도 지원)",
290 "최고 점수만 $.store(plugin 전용 저장소)에 남기고, 그 외 기기 밖 전송은 없어요",
291 "README에 'claude -p, 데스크톱 앱, 모바일에서는 아무것도 그려지지 않는다'고 명시되어 있어요(terminal 전용)"
292 ],
293 "permissions": [
294 "도구 호출 제어",
295 "대화 읽기"
296 ]
297 },
298 {
299 "name": "reels",
300 "displayName": "릴스",
301 "summary": "Claude가 작업하는 동안 YouTube Shorts를 터미널 패널에서 재생하고 작업이 끝나면 자동으로 멈춰요. `/reels`를 입력했을 때만 재생돼요.",
302 "category": "animation",
303 "kind": "upstream",
304 "author": "Hamza Zafar",
305 "license": "MIT",
306 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/reels",
307 "commands": [
308 "/reels"
309 ],
310 "requires": [
311 "Playwright (최초 /reels 실행 시 설치 안내가 떠요: npm install --prefix <plugin> playwright)"
312 ],
313 "notes": [
314 "코드(helper/reels.cjs)까지 직접 읽어 확인했어요. Playwright로 헤드리스 Chrome을 띄워 https://www.youtube.com/shorts 를 열고, 화면을 CDP 스크린캐스트로 캡처해 PNG로 저장한 뒤 패널에 보여줘요. 제어는 로컬 유닉스 소켓(파일 경로를 쓰는 IPC)으로만 하고, 바깥에 열린 네트워크 포트는 아니에요.",
315 "프로필(쿠키 등)은 ~/.claude-mods/reels/profile에 로컬로만 저장돼요."
316 ],
317 "permissions": [
318 "네트워크",
319 "프로그램 실행",
320 "대화 읽기",
321 "환경·설정 읽기",
322 "파일 읽기"
323 ]
324 },
325 {
326 "name": "tool-defense",
327 "displayName": "툴 디펜스",
328 "summary": "Claude의 실제 도구 호출(Bash·Edit·웹·Agent)에 대응하는 적 유닛을 표시하는 타워 디펜스예요. 토큰 소모 없이 입력창 위에서 플레이해요",
329 "category": "animation",
330 "kind": "upstream",
331 "author": "claude-code-templates",
332 "license": "MIT",
333 "homepage": "https://github.com/davila7/claude-code-templates/tree/375af9018a40e330e81542f59054daaa088c21aa/cli-tool/components/mods/games/tool-defense",
334 "commands": [
335 "/defense"
336 ],
337 "requires": [],
338 "notes": [
339 "클릭(마우스)으로 타워를 짓고 업그레이드해요. 일시정지·재시작은 키보드(스페이스/p/r)로도 가능해요",
340 "최고 점수만 $.store(plugin 전용 저장소)에 남기고, 그 외 기기 밖 전송은 없어요",
341 "README에 'claude -p, 데스크톱 앱, 모바일에서는 아무것도 그려지지 않는다'고 명시되어 있어요(마우스 가능한 terminal 전용)"
342 ],
343 "permissions": [
344 "도구 호출 제어",
345 "대화 읽기"
346 ]
347 },
348 {
349 "name": "status-ko",
350 "displayName": "작업 상태 한국어",
351 "summary": "작업 중에는 '읽는 중 · page.tsx'처럼 지금 하는 일을 보여줘요. 끝나면 모델·시간·도구·캐시를 한 줄로 정리해요",
352 "category": "statusline",
353 "kind": "original",
354 "author": "SeongGwangJu",
355 "license": "MIT",
356 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/status-ko",
357 "commands": [],
358 "requires": [],
359 "notes": [],
360 "permissions": [
361 "도구 호출 제어",
362 "대화 읽기"
363 ],
364 "featured": true
365 },
366 {
367 "name": "ctx-strip",
368 "displayName": "컨텍스트 막대",
369 "summary": "컨텍스트 구성(대화·도구·스킬…)을 입력창 위 막대로 표시해요. 서브에이전트가 실행되면 한 줄로 알려줘요",
370 "category": "statusline",
371 "kind": "original",
372 "author": "SeongGwangJu",
373 "license": "MIT",
374 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/ctx-strip",
375 "commands": [
376 "/ctx"
377 ],
378 "requires": [],
379 "notes": [],
380 "permissions": [
381 "프로그램 실행",
382 "파일 쓰기",
383 "도구 호출 제어",
384 "대화 읽기",
385 "환경·설정 읽기"
386 ]
387 },
388 {
389 "name": "agent-radar",
390 "displayName": "에이전트 레이더",
391 "summary": "실행 중인 서브에이전트마다 경과 시간·도구 호출 수·현재 작업을 입력창 위 한 줄로 표시해요. /radar로 전체 목록과 대화 내용을 확인할 수 있어요.",
392 "category": "statusline",
393 "kind": "upstream",
394 "author": "Hamza Zafar",
395 "license": "MIT",
396 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/agent-radar",
397 "commands": [
398 "/radar"
399 ],
400 "requires": [],
401 "notes": [
402 "같은 저장소의 mission-control도 서브에이전트를 추적하지만 코드맵까지 포함한 더 무거운 패널이에요. agent-radar는 가볍고 단일 목적(에이전트 상태)이라 함께 설치해도 역할이 겹치지 않는다고 판단했어요."
403 ],
404 "permissions": [
405 "도구 호출 제어",
406 "대화 읽기"
407 ]
408 },
409 {
410 "name": "browser-lanes",
411 "displayName": "브라우저 레인",
412 "summary": "이 세션에 Playwright 브라우저가 있는지, 현재 누가 쓰는지 입력창 위에 표시해요. 서브에이전트끼리는 차례대로 사용하게 해요.",
413 "category": "statusline",
414 "kind": "upstream",
415 "author": "Hamza Zafar",
416 "license": "MIT",
417 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/browser-lanes",
418 "commands": [
419 "/browser"
420 ],
421 "requires": [
422 "Playwright MCP 서버 (claude mcp add playwright --scope user -- npx @playwright/mcp@latest --isolated 권장)"
423 ],
424 "notes": [
425 "--isolated 없이 Playwright MCP를 쓰면 세션마다 Chrome 프로필을 공유해 두 번째 세션이 'Browser is already in use' 오류를 겪는데, 이 mod는 그 상황을 알려주고 /browser clean으로 정리해 주는 역할이에요. 근본 해결은 --isolated 설정이라고 README에도 명시돼 있어요."
426 ],
427 "permissions": [
428 "프로그램 실행",
429 "도구 호출 제어",
430 "대화 읽기"
431 ]
432 },
433 {
434 "name": "cache-panel",
435 "displayName": "캐시 패널",
436 "summary": "프롬프트 캐시가 만료되기 50분 전에 알려줘요. 주기적 갱신, 한 번 갱신, 대화 압축 중 하나를 예상 비용과 함께 선택할 수 있어요.",
437 "category": "statusline",
438 "kind": "upstream",
439 "author": "Dustin Yuchen Teng",
440 "license": "MIT",
441 "homepage": "https://github.com/danyuchn/claude-mods",
442 "commands": [
443 "/warm"
444 ],
445 "requires": [],
446 "notes": [],
447 "permissions": [
448 "프로그램 실행",
449 "모델 호출",
450 "환경·설정 읽기"
451 ]
452 },
453 {
454 "name": "context-bar",
455 "displayName": "컨텍스트 바 (hamzafer)",
456 "summary": "컨텍스트 창을 /context와 같은 색의 구간별 막대 그래프로 표시해요. 토큰 수·압축 시점·범례도 입력창 위에서 확인할 수 있어요.",
457 "category": "statusline",
458 "kind": "upstream",
459 "author": "Hamza Zafar",
460 "license": "MIT",
461 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/context-bar",
462 "commands": [
463 "/context-bar"
464 ],
465 "requires": [],
466 "notes": [
467 "k-mods 자체 mod인 ctx-strip과 개념이 겹쳐요(둘 다 컨텍스트 창을 막대로 보여줌). 이름은 다르므로(string 충돌 아님) 등록은 했지만, 기계적으로는 ctx-strip이 '막대 + 서브에이전트 한 줄'을 한 번에 보여주는 반면 context-bar는 /context와 동일한 카테고리별 색상·범례·압축 임계값 표시에 집중한 순수 컨텍스트 전용 버전이라는 차이가 있어요. 최종적으로 사용자에게 둘 다 보여줄지, 하나만 추천할지는 교차 조율이 필요해 보여요."
468 ],
469 "permissions": [
470 "대화 읽기"
471 ]
472 },
473 {
474 "name": "flightdeck",
475 "displayName": "플라이트덱 대시보드",
476 "summary": "메인 모델의 상태·비용·컨텍스트, 온콜 아키텍트 상담, 권한 검사, 서브에이전트 카드를 한 화면에 표시해요. 세션 이벤트가 발생하면 실시간으로 갱신해요",
477 "category": "statusline",
478 "kind": "upstream",
479 "author": "Stephen Casella",
480 "license": "MIT",
481 "homepage": "https://github.com/scasella/claude-flightdeck",
482 "commands": [
483 "/flightdeck"
484 ],
485 "requires": [],
486 "notes": [],
487 "permissions": [
488 "도구 호출 제어",
489 "대화 읽기"
490 ]
491 },
492 {
493 "name": "hud",
494 "displayName": "claude-hud 상태 대시보드",
495 "summary": "모델·프로젝트·Git·컨텍스트·사용량·도구·할 일을 HUD 한 줄로 표시해요. 예산·이력·작업 요약·상세 패널·테마도 지원해요.",
496 "category": "statusline",
497 "kind": "upstream",
498 "author": "hoobnn",
499 "license": "MIT",
500 "homepage": "https://github.com/hoobnn/hoobnn-agent-mods/tree/8fb6f67cad9fd023cc7c06b7923518b4bbc45aea/claude-code/hud",
501 "commands": [
502 "/hud"
503 ],
504 "requires": [
505 "Nerd Font (nerd·powerline 테마에서만 필요)"
506 ],
507 "notes": [
508 "[jarrodwatts/claude-hud](https://github.com/jarrodwatts/claude-hud) 0.10.0을 mod로 옮긴 것으로, 별도 LICENSE.claude-hud(MIT, Jarrod Watts)로 원저작권을 유지해요. 화면 언어는 k-mods의 `/config`가 아니라 claude-hud 자신의 설정 파일(`~/.claude/plugins/claude-hud/config.json` 또는 `~/.claude/claude-hud.json`)의 `language` 필드를 따라요(기본값 en). plugin.json userConfig에 language 옵션 자체가 없어 여기서는 language 설정을 권장하지 않았어요.",
509 "`summaryEveryTurns`(기본 5턴, 0으로 끌 수 있음)마다 세션 작업을 한 줄로 요약하는 모델 호출이 있어요. 대화를 포크해 프롬프트 캐시를 재사용하는 호출이라 비용은 적지만 사용자 사용량을 소모해요.",
510 "`extraCmd`(claude-hud의 --extra-cmd, 임의 쉘 명령 실행)는 기본 빈 값이고, 쓰려면 옵션 설정과 `CLAUDE_HUD_ALLOW_EXTRA_CMD=1` 환경 변수를 모두 켜야 해요.",
511 "계정 인증 방식·이메일 일부를 보여주는 기능(claude-hud의 showAuth/showAuthUser)과 다른 로컬 도구로 사용량을 공유하는 파일(externalUsagePath/externalUsageWritePath)은 claude-hud 자체 설정 파일에서만 켤 수 있고 둘 다 기본값이 꺼짐이에요.",
512 "같은 작성자(hoobnn)의 `spinner` mod를 함께 설치하면 pet이 HUD 옆(below 위치일 때)에 표시돼요. spinner가 없어도 정상 동작해요."
513 ],
514 "permissions": [
515 "프로그램 실행",
516 "파일 쓰기",
517 "모델 호출",
518 "도구 호출 제어",
519 "대화 읽기",
520 "환경·설정 읽기",
521 "파일 읽기",
522 "소리"
523 ]
524 },
525 {
526 "name": "mod-usage",
527 "displayName": "사용량 진행률 막대",
528 "summary": "컨텍스트·5시간·7일 사용량을 입력창 위 그라데이션 막대 3개로 표시해요. Desktop/VS Code 전용이며 12개 언어를 지원하지만 한국어는 아직 없어요.",
529 "category": "statusline",
530 "kind": "upstream",
531 "author": "Jack Chiang",
532 "license": "MIT",
533 "homepage": "https://github.com/jack21/claude-mod-usage",
534 "commands": [],
535 "requires": [],
536 "notes": [
537 "한국어는 아직 지원 언어가 아니에요. 한국어 로캘에서도 영어로 표시돼요.",
538 "한국어 로캘(`ko_KR` 등)이어도 지원 목록 12개 언어에 `ko`가 없어서 자동으로 영어로 표시돼요. 번역 패치가 반영되기 전까지는 `/plugin configure`의 언어 선택지에도 한국어가 없어요.",
539 "터미널에서는 그려지지 않아요. Claude Code Desktop/VS Code/모바일 전용이고, CLI는 자체 상태 표시줄을 그대로 써요.",
540 "5시간·7일 막대는 Claude 구독 사용자에게만 보여요. API 키로 쓰면 컨텍스트 막대만 보여요 (원본 README 기준, k-mods가 직접 확인한 건 아니에요).",
541 "macOS에서 `LANG`류 환경변수가 하나도 없을 때만 내부적으로 `/usr/bin/defaults read -g AppleLanguages`를 1회 실행해 시스템 언어를 읽어요. 인자가 고정돼 있고 사용자 입력은 들어가지 않아요."
542 ],
543 "permissions": [
544 "프로그램 실행",
545 "환경·설정 읽기"
546 ]
547 },
548 {
549 "name": "pr-pulse",
550 "displayName": "PR 펄스",
551 "summary": "GitHub PR의 머지 준비 상태·CI 체크·리뷰 코멘트·리뷰 대기열을 입력창 위 한 줄과 패널에 실시간으로 표시해요",
552 "category": "statusline",
553 "kind": "upstream",
554 "author": "Gerric Chaplin",
555 "license": "MIT",
556 "homepage": "https://github.com/gerricchaplin/pr-pulse",
557 "commands": [
558 "/pulse"
559 ],
560 "requires": [
561 "gh CLI 로그인 (gh auth status)"
562 ],
563 "notes": [
564 "GitHub API 호출이 전부 로컬 gh 인증을 그대로 써요. 다른 작업과 시간당 한도를 같이 써요 (15초/60초 주기로 자동 새로고침)."
565 ],
566 "permissions": [
567 "프로그램 실행",
568 "프롬프트 입력"
569 ]
570 },
571 {
572 "name": "prompt-cache-control",
573 "displayName": "프롬프트 캐시 미터",
574 "summary": "요청마다 캐시를 읽고 새로 쓴 양을 입력창 위에 표시해요. 만료가 임박하면 알리고 /compact·/clear 시점을 제안해요",
575 "category": "statusline",
576 "kind": "upstream",
577 "author": "claude-code-templates",
578 "license": "MIT",
579 "homepage": "https://github.com/davila7/claude-code-templates/tree/375af9018a40e330e81542f59054daaa088c21aa/cli-tool/components/mods/observability/prompt-cache-control",
580 "commands": [
581 "/cache"
582 ],
583 "requires": [],
584 "notes": [
585 "TTL(캐시 유지 시간)은 환경변수·설정·계정 종류(구독/API 키)로 자동 추정되고, 요청 간격을 관찰해 스스로 보정해요",
586 "1초 간격 타이머가 돌지만 표시 내용이 바뀔 때만 다시 그려서 유휴 세션엔 비용이 없어요"
587 ],
588 "permissions": [
589 "환경·설정 읽기",
590 "파일 읽기"
591 ]
592 },
593 {
594 "name": "receipt",
595 "displayName": "턴 영수증",
596 "summary": "매 턴이 끝나면 바뀐 파일·실행한 명령·읽은 횟수를 입력창 위 한 줄 요약으로 표시해요. 같은 작업을 반복하면 알려줘요.",
597 "category": "statusline",
598 "kind": "upstream",
599 "author": "hoobnn",
600 "license": "MIT",
601 "homepage": "https://github.com/hoobnn/hoobnn-agent-mods/tree/8fb6f67cad9fd023cc7c06b7923518b4bbc45aea/claude-code/receipt",
602 "commands": [
603 "/receipt"
604 ],
605 "requires": [],
606 "notes": [
607 "같은 작성자의 `hud`·`ts-band`·`todo-bar`·`hitokoto`와 같은 Box/Text 기반 렌더링을 쓰지만, 이 mod의 테스트 스위트만 유일하게 terminal 서피스만 돌려 확인해 surfaces를 terminal로만 표기했어요(desktop이 안 되는 것을 확인한 건 아니고, 작성자가 desktop으로 검증한 기록이 없다는 뜻이에요).",
608 "채팅만 하고 도구를 쓰지 않은 턴은 영수증을 남기지 않고, 다음 턴이 시작되면 자동으로 접혀요."
609 ],
610 "permissions": [
611 "도구 호출 제어",
612 "대화 읽기",
613 "환경·설정 읽기"
614 ]
615 },
616 {
617 "name": "review-watch",
618 "displayName": "리뷰 워치",
619 "summary": "실행 중인 codex review나 '리뷰' 서브에이전트마다 모델·대상·경과 시간을 한 줄로 표시해요. 끝나면 발견 항목 수를 화면 알림으로 알려줘요.",
620 "category": "statusline",
621 "kind": "upstream",
622 "author": "Hamza Zafar",
623 "license": "MIT",
624 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/review-watch",
625 "commands": [],
626 "requires": [
627 "ps, tail (macOS/Linux 기본 제공)",
628 "Codex CLI (선택. codex review 추적 시에만 의미 있음)"
629 ],
630 "notes": [
631 "저장소 설명에 'codex review 감시' 기능이 있다고 해서 codex 바이너리를 직접 실행하는지 코드로 확인했어요. 직접 실행하지 않아요. Claude가 이미 Bash로 실행한 `codex review` 명령을 ps/tail로 관찰만 하고, 실행 여부 결정이나 차단은 merge-gate(같은 저장소)의 역할이에요."
632 ],
633 "permissions": [
634 "프로그램 실행",
635 "도구 호출 제어",
636 "대화 읽기",
637 "환경·설정 읽기"
638 ]
639 },
640 {
641 "name": "statuspane",
642 "displayName": "상태 카드",
643 "summary": "모델, 이펙트, 컨텍스트, 5시간·주간 한도, 비용, 브랜치를 프롬프트 위 카드 하나에 표시해요.",
644 "category": "statusline",
645 "kind": "upstream",
646 "author": "Anji Xu",
647 "license": "MIT",
648 "homepage": "https://github.com/xuanji86/claude-statuspane",
649 "commands": [
650 "/statuspane"
651 ],
652 "requires": [
653 "gh CLI (ciBranch 또는 ciPush 설정을 켰을 때만 필요해요, 기본은 둘 다 꺼져 있어요)"
654 ],
655 "notes": [
656 "데스크톱 앱에서는 모델·이펙트·컨텍스트를 자체적으로 보여주기 때문에 이 카드는 터미널에서만 그려져요.",
657 "Claude Code 2.1.287 이상 필요, 2.1.288에서 테스트됐다고 README에 적혀 있어요."
658 ],
659 "permissions": [
660 "프로그램 실행",
661 "도구 호출 제어",
662 "대화 읽기",
663 "환경·설정 읽기",
664 "파일 읽기"
665 ]
666 },
667 {
668 "name": "taxi-meter",
669 "displayName": "택시 미터기",
670 "summary": "입력창 위 택시 미터기 패널에 세션 요금과 5시간·주간 한도를 표시해요. /meter·/receipt로 자세히 볼 수 있어요.",
671 "category": "statusline",
672 "kind": "upstream",
673 "author": "개발동생 (devbrothers)",
674 "license": "MIT",
675 "homepage": "https://github.com/devbrother2024/devbrothers-mods",
676 "commands": [
677 "/meter",
678 "/receipt"
679 ],
680 "requires": [],
681 "notes": [],
682 "permissions": [
683 "대화 읽기",
684 "소리"
685 ]
686 },
687 {
688 "name": "todo-bar",
689 "displayName": "할 일 진행 상태줄",
690 "summary": "Claude가 만든 할 일 목록의 진행 상황과 경과 시간을 입력창 위 막대로 표시해요.",
691 "category": "statusline",
692 "kind": "upstream",
693 "author": "hoobnn",
694 "license": "MIT",
695 "homepage": "https://github.com/hoobnn/hoobnn-agent-mods/tree/8fb6f67cad9fd023cc7c06b7923518b4bbc45aea/claude-code/todo-bar",
696 "commands": [
697 "/todos"
698 ],
699 "requires": [],
700 "notes": [
701 "TodoWrite·TaskCreate·TaskUpdate 결과만 사후에 읽어서 그리고, 별도 도구를 등록하거나 프롬프트에 끼어들지 않아 토큰을 쓰지 않아요.",
702 "거부되거나 실패한 호출, 서브에이전트 자신의 할 일 목록은 집계하지 않아요.",
703 "세션별 진행 상황은 최근 20개 세션까지 로컬에 보관되어 세션을 재개해도 이어서 보여요."
704 ],
705 "permissions": [
706 "도구 호출 제어",
707 "대화 읽기",
708 "환경·설정 읽기"
709 ]
710 },
711 {
712 "name": "token-weather",
713 "displayName": "토큰 날씨",
714 "summary": "컨텍스트 사용량을 날씨 아이콘과 최근 턴 막대그래프로 입력창 위에 표시해요.",
715 "category": "statusline",
716 "kind": "upstream",
717 "author": "Claude Code DevRel",
718 "license": "Apache-2.0",
719 "homepage": "https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/token-weather",
720 "commands": [],
721 "requires": [],
722 "notes": [
723 "턴이 끝날 때마다 `$.session.usage()`로 컨텍스트 사용률만 다시 읽어요. 추가 네트워크·모델 호출 없어요.",
724 "최근 12번 턴의 토큰 수를 막대그래프로 보여줘요."
725 ],
726 "permissions": [
727 "대화 읽기"
728 ]
729 },
730 {
731 "name": "ts-band",
732 "displayName": "테일스케일 노드 상태줄",
733 "summary": "입력창 위에 Tailscale 노드들의 연결 상태를 표시해요. 노드가 끊기거나 다시 연결되면 화면 알림으로 알려줘요.",
734 "category": "statusline",
735 "kind": "upstream",
736 "author": "hoobnn",
737 "license": "MIT",
738 "homepage": "https://github.com/hoobnn/hoobnn-agent-mods/tree/8fb6f67cad9fd023cc7c06b7923518b4bbc45aea/claude-code/ts-band",
739 "commands": [
740 "/ts"
741 ],
742 "requires": [
743 "tailscale CLI"
744 ],
745 "notes": [
746 "Tailscale이 설치되어 있어야 동작해요(없으면 '찾을 수 없음' 오류만 조용히 표시).",
747 "기본 새로고침 주기는 60초(최소 10초)이고, 조회 실패가 이어지면 최대 10분까지 점점 느리게 재시도해요.",
748 "`tailscalePath`를 비워두면 PATH, Homebrew 설치 경로, macOS 앱 내장 CLI 순서로 자동으로 찾아요."
749 ],
750 "permissions": [
751 "프로그램 실행",
752 "도구 호출 제어",
753 "대화 읽기",
754 "환경·설정 읽기"
755 ]
756 },
757 {
758 "name": "usage-band-pawandeep",
759 "displayName": "사용량 밴드 (5시간·주간)",
760 "summary": "입력창 위에 5시간·주간 사용량 퍼센트와 초기화 카운트다운을 항상 표시해요. 새 채팅·GitHub 푸시 버튼도 함께 제공해요.",
761 "category": "statusline",
762 "kind": "upstream",
763 "author": "Pawandeep",
764 "license": "MIT",
765 "homepage": "https://github.com/pawandeepdhall/claude-mods#usage-band",
766 "commands": [],
767 "requires": [],
768 "notes": [
769 "↑ 푸시 버튼은 git 명령을 직접 실행하지 않아요. 커밋 후 푸시해 달라는 요청을 사용자가 직접 입력한 것처럼 Claude에게 제출할 뿐이라, 실제 git 실행은 평소 권한 설정(permission)을 그대로 따라요.",
770 "+ 새 채팅 버튼은 Windows에서는 번들된 VBScript(wscript.exe)로, macOS에서는 osascript로 Ctrl+N/Cmd+N 키 입력을 흉내 내요.",
771 "같은 저장소의 `next-steps` 플러그인은 이번 심사 대상이 아니에요(모델 호출 `$.model.complete`을 써서 별도 검토가 필요해요).",
772 "비슷한 성격의 사용량 표시 mod로 `mod-usage`(Desktop/VS Code 전용, 다국어)와 `taxi-meter`(택시 미터기 테마, 원화 환산)도 등록돼 있어요. 이 mod는 터미널·데스크톱 모두 되고 새 채팅·GitHub 푸시 버튼이 있다는 점이 달라요."
773 ],
774 "permissions": [
775 "프로그램 실행",
776 "프롬프트 입력"
777 ]
778 },
779 {
780 "name": "mod-store",
781 "displayName": "모드 상점",
782 "summary": "`/k-mods`를 입력하면 카탈로그가 열려요. 버튼으로 mod를 설치하거나 제거할 수 있어요",
783 "category": "tool",
784 "kind": "original",
785 "author": "SeongGwangJu",
786 "license": "MIT",
787 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/mod-store",
788 "commands": [
789 "/k-mods"
790 ],
791 "requires": [],
792 "notes": [],
793 "permissions": [
794 "프로그램 실행",
795 "환경·설정 읽기"
796 ],
797 "featured": true
798 },
799 {
800 "name": "memo-pad",
801 "displayName": "메모장",
802 "summary": "Claude가 일하는 동안 다음에 시킬 일을 적어 두고 버튼 한 번으로 입력창에 추가해요",
803 "category": "tool",
804 "kind": "original",
805 "author": "SeongGwangJu",
806 "license": "MIT",
807 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/memo-pad",
808 "commands": [
809 "/m"
810 ],
811 "requires": [],
812 "notes": [],
813 "permissions": [
814 "프롬프트 입력"
815 ]
816 },
817 {
818 "name": "agent-flow",
819 "displayName": "에이전트 플로우",
820 "summary": "메인 루프와 서브에이전트의 관계를 트리 구조로 표시해요. 각 에이전트에 오간 컨텍스트 양과 답변은 클릭해서 볼 수 있어요",
821 "category": "tool",
822 "kind": "upstream",
823 "author": "claude-code-templates",
824 "license": "MIT",
825 "homepage": "https://github.com/davila7/claude-code-templates/tree/375af9018a40e330e81542f59054daaa088c21aa/cli-tool/components/mods/ui/agent-flow",
826 "commands": [
827 "/agent-flow"
828 ],
829 "requires": [],
830 "notes": [
831 "데스크톱 앱에서의 동작은 README·코드에 명시적 확인이 없어 이번 리뷰에서는 terminal만 표시했어요(테스트도 terminal surface 기준)",
832 "순수 관찰용 mod예요. 모든 훅이 next(e) 결과를 그대로 통과시키고 $.agent.list·$.session.usage만 추가로 읽어요"
833 ],
834 "permissions": [
835 "도구 호출 제어",
836 "대화 읽기"
837 ]
838 },
839 {
840 "name": "agent-quick-menu",
841 "displayName": "퀵 메뉴",
842 "summary": "설치된 플러그인의 명령과 설정을 한 패널에서 찾아 바로 실행할 수 있어요.",
843 "category": "tool",
844 "kind": "upstream",
845 "author": "dasganni",
846 "license": "MIT",
847 "homepage": "https://github.com/agentic-workbench/agent-quick-menu",
848 "commands": [
849 "/menu"
850 ],
851 "requires": [],
852 "notes": [
853 "다른 플러그인의 명령을 대신 실행해 주는 구조라, 자기 플러그인이 아닌 명령은 두 번 눌러야(5초 내 재확인) 실행돼요.",
854 "Claude Code 2.1.287 이상, macOS/Linux 터미널이나 데스크톱 Code 탭 기준이에요. VS Code 채팅 패널은 지원 안 하고 Windows는 저자가 테스트하지 않았다고 README에 적혀 있어요."
855 ],
856 "permissions": [
857 "환경·설정 읽기",
858 "파일 읽기"
859 ]
860 },
861 {
862 "name": "image-view",
863 "displayName": "이미지 미리보기",
864 "summary": "붙여넣은 이미지를 `[Image #1]` 같은 글자 대신 입력창 위 썸네일로 보여줘요.",
865 "category": "tool",
866 "kind": "upstream",
867 "author": "gggodlin",
868 "license": "MIT",
869 "homepage": "https://github.com/GGGODLIN/cc-mod-image-view",
870 "commands": [],
871 "requires": [],
872 "notes": [],
873 "permissions": [
874 "프로그램 실행",
875 "대화 읽기",
876 "환경·설정 읽기",
877 "파일 읽기"
878 ]
879 },
880 {
881 "name": "now-playing",
882 "displayName": "나우 플레잉",
883 "summary": "macOS에서 Spotify로 재생 중인 곡과 가사를 입력창 위에 표시해요. 버튼이나 /music으로 재생·일시정지·이전·다음을 조작할 수 있어요.",
884 "category": "tool",
885 "kind": "upstream",
886 "author": "Hamza Zafar",
887 "license": "MIT",
888 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/now-playing",
889 "commands": [
890 "/music"
891 ],
892 "requires": [
893 "macOS",
894 "Spotify 데스크톱 앱"
895 ],
896 "notes": [
897 "macOS가 아니면 session.start에서 uname -s로 확인해 명령 등록·폴링을 전혀 하지 않고 조용히 꺼져 있어요(Linux/Windows에서 아무 영향 없음)."
898 ],
899 "permissions": [
900 "네트워크",
901 "프로그램 실행"
902 ]
903 },
904 {
905 "name": "paste-view",
906 "displayName": "붙여넣기 미리보기",
907 "summary": "붙여넣은 이미지와 긴 텍스트를 입력창 위에서 미리 확인할 수 있어요.",
908 "category": "tool",
909 "kind": "upstream",
910 "author": "Clément Décou",
911 "license": "MIT",
912 "homepage": "https://github.com/Amorfx/claude-paste-view",
913 "commands": [],
914 "requires": [],
915 "notes": [],
916 "permissions": [
917 "프로그램 실행",
918 "환경·설정 읽기",
919 "파일 읽기"
920 ]
921 },
922 {
923 "name": "pixel-player",
924 "displayName": "픽셀 뮤직 플레이어",
925 "summary": "mpv로 재생목록을 재생하면서 음악에 맞춰 반응하는 픽셀 아트 캐릭터를 패널에 표시해요.",
926 "category": "tool",
927 "kind": "upstream",
928 "author": "chrisluo5311",
929 "license": "MIT",
930 "homepage": "https://github.com/chrisluo5311/Pixel-Play",
931 "commands": [
932 "/music"
933 ],
934 "requires": [
935 "mpv (필수. 없으면 설치 안내만 뜨고 재생되지 않아요)",
936 "yt-dlp (유튜브 링크를 재생하려면 권장)"
937 ],
938 "notes": [
939 "재생목록(`~/.claude/pixel-play/playlist.txt`)에 유튜브 링크·직접 오디오 URL·로컬 파일 경로를 한 줄씩 추가해요. mpv가 그 주소로 직접 접속해 재생할 뿐, 모드 자체가 다른 곳에 접속하지는 않아요.",
940 "재생목록 파일은 플러그인 폴더가 아니라 `~/.claude/` 아래 저장돼 업데이트해도 유지돼요.",
941 "재생 제어(일시정지·재개)는 로컬 유닉스 소켓(IPC)으로만 이뤄지고 네트워크를 쓰지 않아요.",
942 "mpv 실행 인자 끝에 `--` 구분자가 없어서, 재생목록 줄이 `--`로 시작하면 mpv 옵션(예: `--script=`로 임의 Lua 스크립트 로드)으로 해석될 수 있어요. 재생목록은 본인이 직접 적는 로컬 파일이라 위험은 낮지만, 남이 준 재생목록 줄을 그대로 붙여넣지 않는 게 안전해요.",
943 "Windows는 지원하지 않고 macOS에서만 테스트됐어요."
944 ],
945 "permissions": [
946 "프로그램 실행",
947 "파일 쓰기",
948 "환경·설정 읽기",
949 "파일 읽기"
950 ]
951 },
952 {
953 "name": "prompt-rail",
954 "displayName": "프롬프트 레일",
955 "summary": "세션에서 보낸 프롬프트를 탐색 막대에 모아 표시해요. 마우스를 올리면 내용을 미리 보고 클릭하면 해당 지점으로 이동해요.",
956 "category": "tool",
957 "kind": "upstream",
958 "author": "oikon48",
959 "license": "MIT",
960 "homepage": "https://github.com/oikon48/prompt-rail",
961 "commands": [
962 "/prompt-rail"
963 ],
964 "requires": [],
965 "notes": [],
966 "permissions": [
967 "프로그램 실행",
968 "대화 읽기",
969 "파일 읽기"
970 ]
971 },
972 {
973 "name": "replay-theater",
974 "displayName": "Replay 극장",
975 "summary": "이번 턴에서 Claude가 고친 파일을 한 단계씩 diff로 다시 확인할 수 있어요.",
976 "category": "tool",
977 "kind": "upstream",
978 "author": "Claude Code DevRel",
979 "license": "Apache-2.0",
980 "homepage": "https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/replay-theater",
981 "commands": [
982 "/replay"
983 ],
984 "requires": [],
985 "notes": [
986 "Write로 기존 파일을 덮어쓸 때는 그 파일의 원래 내용을 읽어와 diff를 만들어요(그 외 다른 파일은 읽지 않아요).",
987 "리플레이 기록은 메모리에만 있어서 세션을 새로고침하면 사라져요."
988 ],
989 "permissions": [
990 "도구 호출 제어",
991 "대화 읽기",
992 "파일 읽기"
993 ]
994 },
995 {
996 "name": "session-wrapped",
997 "displayName": "세션 랩드",
998 "summary": "`/wrapped`로 이번 세션의 도구 호출·테스트·비용 통계를 애니메이션으로 표시해요. 공유용 PNG 카드는 데스크톱에 저장해요.",
999 "category": "tool",
1000 "kind": "upstream",
1001 "author": "OneWave AI",
1002 "license": "MIT",
1003 "homepage": "https://github.com/OneWave-AI/claude-code-mods/tree/main/session-wrapped",
1004 "commands": [
1005 "/wrapped"
1006 ],
1007 "requires": [
1008 "python3"
1009 ],
1010 "notes": [
1011 "PNG 카드는 `~/Desktop`에 저장돼요.",
1012 "세션 종료 시 토스트 알림만 자동으로 뜨고, 실제 리캡 화면은 `/wrapped`를 직접 실행해야 열려요.",
1013 "통계는 전부 도구 호출 횟수·테스트 결과·토큰 사용량을 코드로 집계한 값이고, 요약 문구 생성에 모델을 쓰지 않아요."
1014 ],
1015 "permissions": [
1016 "프로그램 실행",
1017 "도구 호출 제어",
1018 "대화 읽기",
1019 "환경·설정 읽기"
1020 ]
1021 },
1022 {
1023 "name": "taxi-blackbox",
1024 "displayName": "택시 블랙박스",
1025 "summary": "도구 호출을 기록하고 오류나 거부 직전의 기록을 /blackbox에서 다시 확인할 수 있어요. 토큰·비밀번호는 가려서 기록해요.",
1026 "category": "tool",
1027 "kind": "upstream",
1028 "author": "개발동생 (devbrothers)",
1029 "license": "MIT",
1030 "homepage": "https://github.com/devbrother2024/devbrothers-mods",
1031 "commands": [
1032 "/blackbox"
1033 ],
1034 "requires": [],
1035 "notes": [],
1036 "permissions": [
1037 "도구 호출 제어",
1038 "대화 읽기"
1039 ]
1040 },
1041 {
1042 "name": "taxi-navi",
1043 "displayName": "택시 내비게이션",
1044 "summary": "할 일 목록에서 현재 진행 상태와 다음 할 일을 표시해요. 계획이 바뀌면 새 순서에 맞춰 목록을 갱신해요.",
1045 "category": "tool",
1046 "kind": "upstream",
1047 "author": "개발동생 (devbrothers)",
1048 "license": "MIT",
1049 "homepage": "https://github.com/devbrother2024/devbrothers-mods",
1050 "commands": [
1051 "/navi"
1052 ],
1053 "requires": [],
1054 "notes": [],
1055 "permissions": [
1056 "도구 호출 제어",
1057 "환경·설정 읽기",
1058 "소리"
1059 ]
1060 },
1061 {
1062 "name": "done-alarm",
1063 "displayName": "작업 끝 알림",
1064 "summary": "오래 걸린 작업이 끝나거나 확인이 필요하면 맥 알림·한국어 음성·폰 푸시(ntfy·텔레그램·슬랙)로 알려줘요",
1065 "category": "automation",
1066 "kind": "original",
1067 "author": "SeongGwangJu",
1068 "license": "MIT",
1069 "homepage": "https://github.com/SeongGwangJu/k-mods/tree/main/mods/done-alarm",
1070 "commands": [
1071 "/alarm"
1072 ],
1073 "requires": [],
1074 "notes": [],
1075 "permissions": [
1076 "네트워크",
1077 "프로그램 실행",
1078 "도구 호출 제어",
1079 "대화 읽기",
1080 "소리"
1081 ]
1082 },
1083 {
1084 "name": "agent-compact-advisor",
1085 "displayName": "컴팩트 타이밍 어드바이저",
1086 "summary": "지금이 /compact 하기 좋은 때인지 상태줄에 0~100점으로 표시해요. 압축할 때마다 목표·결정·남은 일을 포함한 보존 템플릿을 자동으로 추가해요.",
1087 "category": "automation",
1088 "kind": "upstream",
1089 "author": "apolenkov",
1090 "license": "MIT",
1091 "homepage": "https://github.com/apolenkov/agent-compact-advisor",
1092 "commands": [
1093 "/compact-advisor"
1094 ],
1095 "requires": [],
1096 "notes": [
1097 "P1(목표 달성 확률, 점수 비중 20)은 로컬 127.0.0.1:8010에 떠 있는 'Kev의 System One'이라는 별도 서비스가 있어야 값이 나와요. 없으면 조용히 빠지고 나머지 항목만으로 재계산돼요. 추가 설치 없이도 상태줄 점수·압축 보존 템플릿 같은 핵심 기능은 전부 동작해요.",
1098 "같은 저자의 다른 mod agent-shell-watch가 설치돼 있으면 백그라운드 작업(대기·실행 중)도 점수에 반영돼요. 없으면 그 부분이 '알 수 없음' 취급돼 점수가 60점에 캡돼요(정상 동작, 설치 필수 아님).",
1099 "리더오버 판정은 에이전트의 마지막 답변에서 정해진 접두어로 시작하는 줄만 봐요. 프롬프트에 '해야 할 일이 남아있다'는 식의 자연어 서술은 감지 못 해요(README에 명시된 한계)."
1100 ],
1101 "permissions": [
1102 "네트워크",
1103 "프로그램 실행",
1104 "도구 호출 제어",
1105 "대화 읽기"
1106 ]
1107 },
1108 {
1109 "name": "auto-handoff",
1110 "displayName": "오토 핸드오프",
1111 "summary": "컨텍스트가 한도에 도달하기 전에 Haiku가 요약한 브리핑을 만들고 새 대화로 전환해요. 필요하면 브리핑 페이지를 따로 열어 확인할 수 있어요.",
1112 "category": "automation",
1113 "kind": "upstream",
1114 "author": "Alex Hillman",
1115 "license": "MIT",
1116 "homepage": "https://github.com/alexknowshtml/claude-auto-handoff",
1117 "commands": [],
1118 "requires": [],
1119 "notes": [],
1120 "permissions": [
1121 "프로그램 실행",
1122 "파일 쓰기",
1123 "모델 호출",
1124 "도구 호출 제어",
1125 "프롬프트 입력",
1126 "대화 읽기",
1127 "환경·설정 읽기",
1128 "파일 읽기"
1129 ]
1130 },
1131 {
1132 "name": "ctx-handoff",
1133 "displayName": "컨텍스트 핸드오프",
1134 "summary": "컨텍스트가 한계에 가까워지면 새 대화용 핸드오프를 만들고 자동으로 전환해요. 사용자가 55분 동안 입력하지 않으면 프롬프트 캐시를 최대 3번 갱신해요.",
1135 "category": "automation",
1136 "kind": "upstream",
1137 "author": "cablate",
1138 "license": "MIT",
1139 "homepage": "https://github.com/cablate/ctx-handoff-mod",
1140 "commands": [
1141 "/handoff"
1142 ],
1143 "requires": [],
1144 "notes": [],
1145 "permissions": [
1146 "파일 쓰기",
1147 "모델 호출",
1148 "프롬프트 입력",
1149 "대화 읽기",
1150 "환경·설정 읽기",
1151 "파일 읽기"
1152 ]
1153 },
1154 {
1155 "name": "jev-skill-suggestion",
1156 "displayName": "스킬 자동 추천",
1157 "summary": "프롬프트마다 맞는 스킬 하나를 판단해 자동으로 불러오고, 스킬 목록은 컨텍스트에 넣지 않아요. API 키가 있으면 TypeSafe Jev로, 없으면 Claude 분류기로 판단해요",
1158 "category": "automation",
1159 "kind": "upstream",
1160 "author": "claude-code-templates",
1161 "license": "MIT",
1162 "homepage": "https://github.com/davila7/claude-code-templates/tree/375af9018a40e330e81542f59054daaa088c21aa/cli-tool/components/mods/productivity/jev-skill-suggestion",
1163 "commands": [
1164 "/jev-skill-suggestion:setup"
1165 ],
1166 "requires": [],
1167 "notes": [
1168 "API 키(typesafeApiKey 또는 gatewayApiKey)를 넣으면 프롬프트 전문과 후보 스킬 이름·설명, 선별된 스킬 SKILL.md 앞부분이 TypeSafe 서버로 전송돼요(저자가 코드 주석·README에 직접 명시한 사실)",
1169 "키가 없으면 Claude Code 자체 모델 분류기($.model.classify)를 프롬프트마다 호출해 사용자 Claude 사용량을 소모해요",
1170 "모든 실패 경로가 fail-open이에요. 요청 실패나 타임아웃(기본 800ms)이면 제안 없이 프롬프트를 그대로 통과시켜요",
1171 "/jev-skill-suggestion:setup은 mod가 직접 설정 파일을 쓰지 않고, Claude에게 변경 계획을 프롬프트로 건네 Claude의 Edit 도구로 사용자 확인을 거쳐 수정하게 해요"
1172 ],
1173 "permissions": [
1174 "네트워크",
1175 "모델 호출",
1176 "대화 읽기",
1177 "환경·설정 읽기",
1178 "파일 읽기"
1179 ]
1180 },
1181 {
1182 "name": "next-steps",
1183 "displayName": "다음 할 일 제안",
1184 "summary": "턴이 끝날 때마다 다음에 보낼 법한 프롬프트 2~3개를 입력창 위에 제안해요. 숫자 키를 누르면 선택한 문구가 입력창에 입력돼요.",
1185 "category": "automation",
1186 "kind": "upstream",
1187 "author": "Hamza Zafar",
1188 "license": "MIT",
1189 "homepage": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/next-steps",
1190 "commands": [],
1191 "requires": [],
1192 "notes": [
1193 "같은 저장소의 where-am-i와 서로 인지하도록 짜여 있어요. next-steps 목록이 떠 있는 동안 where-am-i는 자기 '다음 할 일' 칸을 비워서 같은 내용이 중복 표시되지 않게 해요."
1194 ],
1195 "permissions": [
1196 "모델 호출",
1197 "대화 읽기"
1198 ]
1199 },
1200 {hooks/lib.ts 261 lines1// $를 받지 않는 순수 함수만 모아 둔 파일. register.ts가 이 함수들을 가져다 쓴다.
2// 테스트하기 쉽도록 $, UI 엘리먼트, 부수효과를 전혀 참조하지 않는다.
3
4import { MARKETPLACE, PERMISSION_ORDER, type CatalogEntry, type CategoryId } from './catalog'
5
6// ---------------------------------------------------------------------------
7// 카탈로그: 필터·정렬·숨김
8// ---------------------------------------------------------------------------
9
10/** 상점 자신(selfName)을 목록에서 뺀 카탈로그를 돌려준다. */
11export function visibleCatalog(
12 catalog: readonly CatalogEntry[],
13 selfName: string,
14): CatalogEntry[] {
15 return catalog.filter(entry => entry.name !== selfName)
16}
17
18/** 탭(카테고리)으로 거른다. 'all'이면 전체를 그대로 돌려준다. */
19export function filterByTab(
20 catalog: readonly CatalogEntry[],
21 tab: CategoryId | 'all',
22): CatalogEntry[] {
23 return tab === 'all' ? [...catalog] : catalog.filter(entry => entry.category === tab)
24}
25
26/** featured 항목을 앞으로 보낸다. 나머지는 원래 순서를 지킨다(안정 정렬). */
27export function sortFeaturedFirst(catalog: readonly CatalogEntry[]): CatalogEntry[] {
28 return [...catalog].sort((a, b) => Number(b.featured === true) - Number(a.featured === true))
29}
30
31/** 탭 버튼에 보일 개수. 'all' 키에 전체 개수가 들어간다. */
32export function categoryCounts(
33 catalog: readonly CatalogEntry[],
34): Record<'all' | CategoryId, number> {
35 const counts = { all: catalog.length } as Record<'all' | CategoryId, number>
36 for (const entry of catalog) {
37 counts[entry.category] = (counts[entry.category] ?? 0) + 1
38 }
39 return counts
40}
41
42// ---------------------------------------------------------------------------
43// 폭 3단계
44// ---------------------------------------------------------------------------
45
46export type WidthTier = 'wide' | 'normal' | 'narrow'
47
48/** 넓음(>=100) · 보통(70~99) · 좁음(<70). CONTRIBUTING.md 화면 원칙 3번. */
49export function widthTierOf(columns: number): WidthTier {
50 if (columns >= 100) return 'wide'
51 if (columns >= 70) return 'normal'
52 return 'narrow'
53}
54
55// ---------------------------------------------------------------------------
56// 권한 칩: 가장 민감한 권한을 고른다
57// ---------------------------------------------------------------------------
58
59// 민감도 높은 순. 네트워크 > 명령 실행 > 파일 쓰기 > 대화 읽기 > ... > 화면만(가장 약함).
60// 민감한 것부터. scripts/build.mjs가 catalog.ts에 생성하는 순서를 그대로 쓴다
61export const PERMISSION_PRIORITY: readonly string[] = PERMISSION_ORDER
62
63function permissionRank(permission: string): number {
64 const index = PERMISSION_PRIORITY.indexOf(permission)
65 return index === -1 ? PERMISSION_PRIORITY.length : index
66}
67
68/** 목록 중 가장 민감한 권한 라벨 하나를 돌려준다. 비어 있으면 '화면만'. */
69export function mostSensitivePermission(permissions: readonly string[]): string {
70 if (permissions.length === 0) return '화면만'
71 return permissions.reduce((best, current) =>
72 permissionRank(current) < permissionRank(best) ? current : best,
73 )
74}
75
76export type PermissionDisplay = {
77 primary: string
78 extra: string[]
79}
80
81/**
82 * 폭 단계에 맞춰 보여줄 권한을 고른다. 좁은 화면에서는 가장 민감한 것 하나만,
83 * 넓은 화면에서는 전부, 보통 화면에서는 최대 2개까지 추가로 보여준다.
84 */
85export function permissionDisplayOf(
86 permissions: readonly string[],
87 tier: WidthTier,
88): PermissionDisplay {
89 if (permissions.length === 0) return { primary: '화면만', extra: [] }
90 const primary = mostSensitivePermission(permissions)
91 if (tier === 'narrow') return { primary, extra: [] }
92 const rest = permissions.filter(permission => permission !== primary)
93 const maxExtra = tier === 'wide' ? rest.length : Math.min(rest.length, 2)
94 return { primary, extra: rest.slice(0, maxExtra) }
95}
96
97// ---------------------------------------------------------------------------
98// kind 칩 라벨·색
99// ---------------------------------------------------------------------------
100
101export type ChipColor = 'claude' | 'warning' | 'subtle' | 'success' | 'permission' | 'error' | 'suggestion'
102
103export const KIND_LABEL: Record<CatalogEntry['kind'], string> = {
104 upstream: '원본',
105 patched: '수정판',
106 original: '오리지널',
107 bundle: '묶음',
108}
109
110export const KIND_COLOR: Record<CatalogEntry['kind'], ChipColor> = {
111 upstream: 'subtle',
112 patched: 'warning',
113 original: 'claude',
114 bundle: 'success',
115}
116
117// ---------------------------------------------------------------------------
118// claude 버전 비교
119// ---------------------------------------------------------------------------
120
121export const MIN_CLAUDE_VERSION = '2.1.287'
122
123/** "2.1.291" 같은 점 구분 숫자 버전을 비교 가능한 튜플로 바꾼다. */
124export function parseVersionTuple(version: string): number[] {
125 return version
126 .trim()
127 .split('.')
128 .map(part => {
129 const n = Number.parseInt(part, 10)
130 return Number.isFinite(n) ? n : 0
131 })
132}
133
134/** version이 min 이상이면 true. 자리 수가 달라도(2.1 vs 2.1.0) 부족한 자리는 0으로 본다. */
135export function isVersionAtLeast(version: string, min: string): boolean {
136 const v = parseVersionTuple(version)
137 const m = parseVersionTuple(min)
138 const length = Math.max(v.length, m.length)
139 for (let i = 0; i < length; i++) {
140 const a = v[i] ?? 0
141 const b = m[i] ?? 0
142 if (a !== b) return a > b
143 }
144 return true
145}
146
147/** "2.1.291 (Claude Code)" 같은 출력에서 첫 x.y.z 버전 토큰을 뽑는다. */
148export function firstVersionToken(text: string): string | null {
149 const match = /(\d+\.\d+\.\d+)/.exec(text)
150 return match?.[1] ?? null
151}
152
153// ---------------------------------------------------------------------------
154// 설치 명령 조립
155// ---------------------------------------------------------------------------
156
157export function installArgsFor(name: string): string[] {
158 return ['claude', 'plugin', 'install', `${name}@${MARKETPLACE}`, '--scope', 'user']
159}
160
161export function uninstallArgsFor(name: string): string[] {
162 return ['claude', 'plugin', 'uninstall', `${name}@${MARKETPLACE}`, '--scope', 'user']
163}
164
165export function commandLineFor(argv: readonly string[]): string {
166 return argv.join(' ')
167}
168
169export const MARKETPLACE_ADD_COMMAND = `/plugin marketplace add SeongGwangJu/${MARKETPLACE}`
170
171// ---------------------------------------------------------------------------
172// $.process.run 결과 해석
173// ---------------------------------------------------------------------------
174
175export type ProcessResult = {
176 exitCode: number
177 stdout: string
178 stderr: string
179}
180
181export type InstallOutcome =
182 | { kind: 'installed'; message: string }
183 | { kind: 'already'; message: string }
184 | { kind: 'marketplace-missing'; message: string }
185 | { kind: 'failed'; message: string }
186
187function isMarketplaceMissing(text: string): boolean {
188 return /marketplace/i.test(text) && /not found/i.test(text)
189}
190
191export function parseInstallOutcome(result: ProcessResult): InstallOutcome {
192 const text = `${result.stdout}\n${result.stderr}`.trim()
193 if (isMarketplaceMissing(text)) return { kind: 'marketplace-missing', message: text }
194 if (result.exitCode === 0) {
195 return /already installed/i.test(text)
196 ? { kind: 'already', message: text }
197 : { kind: 'installed', message: text }
198 }
199 return { kind: 'failed', message: text || `종료 코드 ${result.exitCode}` }
200}
201
202export type UninstallOutcome =
203 | { kind: 'uninstalled'; message: string }
204 | { kind: 'failed'; message: string }
205
206export function parseUninstallOutcome(result: ProcessResult): UninstallOutcome {
207 const text = `${result.stdout}\n${result.stderr}`.trim()
208 if (result.exitCode === 0) return { kind: 'uninstalled', message: text }
209 return { kind: 'failed', message: text || `종료 코드 ${result.exitCode}` }
210}
211
212// ---------------------------------------------------------------------------
213// 설치 상태 읽기
214// ---------------------------------------------------------------------------
215
216/**
217 * $.settings.read()가 돌려준 값에서 enabledPlugins를 읽어, 카탈로그 중
218 * "<name>@k-mods": true 로 적힌 이름만 설치됨으로 본다.
219 */
220export function installedNamesFrom(
221 settings: Record<string, unknown>,
222 catalog: readonly CatalogEntry[],
223): Set<string> {
224 const result = new Set<string>()
225 const enabled = settings.enabledPlugins
226 if (enabled === null || typeof enabled !== 'object') return result
227 const map = enabled as Record<string, unknown>
228 for (const entry of catalog) {
229 if (map[`${entry.name}@${MARKETPLACE}`] === true) result.add(entry.name)
230 }
231 return result
232}
233
234// ---------------------------------------------------------------------------
235// 배너(오류 안내)
236// ---------------------------------------------------------------------------
237
238export type Banner =
239 | { kind: 'none' }
240 | { kind: 'no-marketplace'; addCommand: string }
241 | { kind: 'error'; text: string; copyText: string }
242
243export function errorBannerOf(argv: readonly string[], message: string): Banner {
244 const short = message.length > 160 ? `${message.slice(0, 160)}…` : message
245 return {
246 kind: 'error',
247 text: short === '' ? '명령이 실패했어요.' : `명령이 실패했어요: ${short}`,
248 copyText: commandLineFor(argv),
249 }
250}
251
252export function messageOf(error: unknown): string {
253 if (error instanceof Error) return error.message
254 if (typeof error === 'string') return error
255 try {
256 return JSON.stringify(error)
257 } catch {
258 return String(error)
259 }
260}
261hooks/view.ts 286 lines1// 패널 화면을 순수하게 조립하는 함수들. $를 전혀 참조하지 않는다 — register.ts가
2// $.ui.resolve(e)로 얻은 엘리먼트(kit)와 모델, 콜백(actions)만 건네준다.
3
4import type {
5 BoxProps,
6 ButtonProps,
7 ElementConstructor,
8 LinkProps,
9 RenderElement,
10 RenderSurface,
11 TextProps,
12} from 'claude-code'
13
14import { CATEGORIES, type CatalogEntry, type CategoryId } from './catalog'
15import {
16 type Banner,
17 KIND_COLOR,
18 KIND_LABEL,
19 categoryCounts,
20 filterByTab,
21 permissionDisplayOf,
22 sortFeaturedFirst,
23 type WidthTier,
24} from './lib'
25
26export type Kit = {
27 Box: ElementConstructor<BoxProps>
28 Text: ElementConstructor<TextProps>
29 Button: ElementConstructor<ButtonProps>
30 Link: ElementConstructor<LinkProps>
31}
32
33export type StoreModel = {
34 tab: CategoryId | 'all'
35 expanded: string | null
36 installed: ReadonlySet<string>
37 busy: ReadonlySet<string>
38 banner: Banner
39 justInstalled: string | null
40 tier: WidthTier
41}
42
43export type StoreActions = {
44 setTab: (tab: CategoryId | 'all') => void
45 toggleDetail: (name: string) => void
46 install: (name: string) => void
47 uninstall: (name: string) => void
48 applyReload: () => void
49 copyCommand: (text: string, surface: RenderSurface) => void
50 dismissBanner: () => void
51}
52
53/** 상점 패널 전체를 그린다. catalog는 이미 자기 자신(mod-store)을 뺀 목록이어야 한다. */
54export function paneView(
55 kit: Kit,
56 catalog: readonly CatalogEntry[],
57 model: StoreModel,
58 actions: StoreActions,
59): RenderElement {
60 const { Box, Text } = kit
61 const counts = categoryCounts(catalog)
62 const shown = filterByTab(sortFeaturedFirst(catalog), model.tab)
63
64 return Box({
65 flexDirection: 'column',
66 children: [
67 Text({ bold: true, children: ['k-mods 모드 상점'] }),
68 ...bannerRows(kit, model, actions),
69 ...justInstalledRows(kit, model, actions),
70 Text({ children: [' '] }),
71 tabsRow(kit, model, actions, counts),
72 Text({ children: [' '] }),
73 ...(shown.length === 0
74 ? [Text({ dimColor: true, children: ['이 카테고리에는 아직 항목이 없어요.'] })]
75 : shown.flatMap(entry => entryRows(kit, entry, model, actions))),
76 Text({ dimColor: true, children: ['Tab 이동 · Enter 누르기 · Esc 닫기'] }),
77 ],
78 })
79}
80
81function bannerRows(kit: Kit, model: StoreModel, actions: StoreActions): RenderElement[] {
82 const { Box, Text, Button } = kit
83 if (model.banner.kind === 'none') return []
84
85 // 세로로 쌓는다: row로 두면 좁은 화면에서 Text마다 따로 줄바꿈되어 글자가 뒤섞인다
86 // (실제 세션에서 확인한 문제 — 한 줄짜리 문장이 Box 폭에 맞춰 조각나 보였다).
87 if (model.banner.kind === 'no-marketplace') {
88 const banner = model.banner
89 return [
90 Box({
91 flexDirection: 'column',
92 children: [
93 Text({ color: 'warning', children: ['마켓플레이스가 아직 없어요.'] }),
94 Box({
95 flexDirection: 'row',
96 columnGap: 1,
97 children: [
98 Text({ dimColor: true, children: [banner.addCommand] }),
99 Button({
100 key: 'banner-copy',
101 label: '복사',
102 plain: true,
103 onPress: press => actions.copyCommand(banner.addCommand, press.surface),
104 }),
105 ],
106 }),
107 ],
108 }),
109 ]
110 }
111
112 const banner = model.banner
113 return [
114 Box({
115 flexDirection: 'column',
116 children: [
117 Text({ color: 'error', children: [banner.text] }),
118 Box({
119 flexDirection: 'row',
120 columnGap: 2,
121 children: [
122 Button({
123 key: 'banner-copy',
124 label: '복사',
125 plain: true,
126 onPress: press => actions.copyCommand(banner.copyText, press.surface),
127 }),
128 Button({ key: 'banner-dismiss', label: '닫기', plain: true, onPress: () => actions.dismissBanner() }),
129 ],
130 }),
131 ],
132 }),
133 ]
134}
135
136function justInstalledRows(kit: Kit, model: StoreModel, actions: StoreActions): RenderElement[] {
137 if (model.justInstalled === null) return []
138 const { Box, Text, Button } = kit
139 return [
140 Box({
141 flexDirection: 'row',
142 columnGap: 1,
143 children: [
144 Text({ color: 'success', children: ['설치했어요.'] }),
145 Button({ key: 'apply-reload', label: '지금 적용', onPress: () => actions.applyReload() }),
146 ],
147 }),
148 ]
149}
150
151function tabsRow(
152 kit: Kit,
153 model: StoreModel,
154 actions: StoreActions,
155 counts: Record<'all' | CategoryId, number>,
156): RenderElement {
157 const { Box, Button } = kit
158 const tabs: { id: CategoryId | 'all'; label: string; hotkey: string }[] = [
159 { id: 'all', label: '전체', hotkey: '0' },
160 ...CATEGORIES.map((category, index) => ({
161 id: category.id,
162 label: category.label,
163 hotkey: String(index + 1),
164 })),
165 ]
166
167 return Box({
168 flexDirection: 'row',
169 flexWrap: 'wrap',
170 columnGap: 2,
171 children: tabs.map(item =>
172 Button({
173 // plain + hotkey가 이미 "숫자: 라벨" 형태로 그려 주므로 라벨에 다시 적지 않는다
174 key: `tab-${item.id}`,
175 label: `${item.label} (${counts[item.id] ?? 0})`,
176 hotkey: item.hotkey,
177 plain: true,
178 dimColor: model.tab !== item.id,
179 onPress: () => actions.setTab(item.id),
180 }),
181 ),
182 })
183}
184
185function entryRows(
186 kit: Kit,
187 entry: CatalogEntry,
188 model: StoreModel,
189 actions: StoreActions,
190): RenderElement[] {
191 const { Box, Text, Button } = kit
192 const isInstalled = model.installed.has(entry.name)
193 const isBusy = model.busy.has(entry.name)
194 const isExpanded = model.expanded === entry.name
195 const permissions = permissionDisplayOf(entry.permissions, model.tier)
196
197 const nameLine = Box({
198 flexDirection: 'row',
199 columnGap: 1,
200 children: [
201 Text({
202 backgroundColor: KIND_COLOR[entry.kind],
203 color: 'inverseText',
204 children: [` ${KIND_LABEL[entry.kind]} `],
205 }),
206 Text({ bold: true, children: [entry.displayName] }),
207 ...(isInstalled
208 ? [Text({ backgroundColor: 'success', color: 'inverseText', children: [' 설치됨 '] })]
209 : []),
210 ],
211 })
212
213 const summaryLine = Text({ dimColor: true, children: [entry.summary] })
214
215 const permissionTexts: RenderElement[] = [Text({ color: 'permission', children: [permissions.primary] })]
216 if (permissions.extra.length > 0) {
217 permissionTexts.push(Text({ dimColor: true, children: [' · ' + permissions.extra.join(' · ')] }))
218 }
219
220 const installLabel = isBusy
221 ? isInstalled
222 ? '제거 중…'
223 : '설치 중…'
224 : isInstalled
225 ? '설치됨 · 제거'
226 : '설치'
227
228 const installButton = Button({
229 key: `install-${entry.name}`,
230 label: installLabel,
231 dimColor: isBusy,
232 onPress: () => {
233 if (isBusy) return
234 if (isInstalled) actions.uninstall(entry.name)
235 else actions.install(entry.name)
236 },
237 })
238
239 const detailButton = Button({
240 key: `detail-${entry.name}`,
241 label: isExpanded ? '접기' : '자세히',
242 plain: true,
243 onPress: () => actions.toggleDetail(entry.name),
244 })
245
246 const actionLines: RenderElement[] =
247 model.tier === 'narrow'
248 ? [
249 Box({ flexDirection: 'row', children: permissionTexts }),
250 Box({ flexDirection: 'row', columnGap: 2, children: [installButton, detailButton] }),
251 ]
252 : [
253 Box({
254 flexDirection: 'row',
255 columnGap: 2,
256 children: [...permissionTexts, installButton, detailButton],
257 }),
258 ]
259
260 const rows: RenderElement[] = [nameLine]
261 if (model.tier !== 'narrow') rows.push(summaryLine)
262 rows.push(...actionLines)
263 if (isExpanded) rows.push(detailBlock(kit, entry))
264 rows.push(Text({ children: [' '] }))
265
266 return rows
267}
268
269function detailBlock(kit: Kit, entry: CatalogEntry): RenderElement {
270 const { Box, Text, Link } = kit
271 const commands = entry.commands.length > 0 ? entry.commands.join(', ') : '없음'
272 const requires = entry.requires.length > 0 ? entry.requires.join(', ') : '없음'
273
274 return Box({
275 flexDirection: 'column',
276 paddingLeft: 2,
277 children: [
278 Text({ dimColor: true, children: [`제작자 ${entry.author} · 라이선스 ${entry.license}`] }),
279 Text({ dimColor: true, children: [`명령 ${commands}`] }),
280 Text({ dimColor: true, children: [`준비물 ${requires}`] }),
281 ...entry.notes.map(note => Text({ dimColor: true, children: [`· ${note}`] })),
282 ...(entry.homepage !== undefined ? [Link({ href: entry.homepage, label: '홈페이지' })] : []),
283 ],
284 })
285}
286