입력창 위에 컨텍스트 창 사용량을 카테고리별 색 막대로 보여준다

새 백엔드 프로젝트를 시작할 때 가져다 쓰는 Claude Code 기본 세팅입니다.
에이전트, 스킬, 커맨드를 이 저장소에서 만들고 검증한 뒤, install.sh로 전역(~/.claude)이나 프로젝트(.claude/)에 심링크를 걸어 씁니다. 다루는 범위는 백엔드(REST API, 서비스, 영속성)이고, 특정 프로젝트나 스택에 묶이지 않게 작성합니다.
my-harness/
├── agents/ # 서브에이전트
├── skills/ # 스택별 구현 패턴
├── commands/ # 슬래시 커맨드
├── rules/ # 규칙 (경로별 지침)
├── hooks/ # 훅 (폴더 하나 = 훅 하나, install.sh가 settings에 병합)
├── mods/ # Claude Code 모드 (폴더 하나 = 모드 하나, install.sh가 전역 settings에 등록)
├── plugins/ # 외부 플러그인 목록 (파일 하나 = 플러그인 하나, install.sh가 claude plugin install로 설치)
├── prompts/ # 복사해 쓰는 프롬프트 (설치 대상 아님)
├── docs/ # 플러그인 사용법, guide/ AI 사용 가이드 (설치 대상 아님)
├── playground/ # 방식별 비교 실험 (설치 대상 아님)
├── install.sh
└── CLAUDE.md
기능 개발 — feature-pm이 나머지 넷을 순서대로 부립니다.
| 이름 | 하는 일 |
|---|---|
feature-pm | 요구사항 분해, 스택 확정, 워커 조율 (메인 세션에서 직접 호출) |
backend-designer | API와 DB 스키마를 함께 설계 |
backend-impl | 설계를 코드로 구현 |
boundary-verifier | 설계와 구현이 서로 맞는지 검증 |
test-writer | 단위·통합 테스트 작성 |
진단 — 코드를 직접 고치지 않고 원인과 개선안만 알려 줍니다.
| 이름 | 하는 일 |
|---|---|
architecture-expert | 구조·의존성 진단, 기술 선택 비교 |
code-reviewer | 가독성, 네이밍, 에러 처리, 중복 리뷰 |
debugger | 버그 재현, 근본 원인 특정 |
performance-optimizer | 병목, N+1, 복잡도 진단 |
security-auditor | 인증·인가, 인젝션 등 보안 점검 |
| 이름 | 하는 일 |
|---|---|
spring-boot | Spring Boot 3.x 구현 패턴 |
nestjs | NestJS 구현 패턴 |
screen-spec-analyzer | 화면 캡처나 화면정의서에서 API 응답 필드 추출 |
| 이름 | 하는 일 |
|---|---|
/commit | 변경을 논리 단위로 나눠 커밋 |
/push | 보호 브랜치와 강제 푸시를 확인하고 푸시 |
/pre-pr | PR 올리기 전 코드 리뷰 (테스트·린트·진단 후 BLOCK/READY 판정) |
/post-pr | PR의 CI 결과와 리뷰 코멘트 대응, 머지 후 브랜치 정리 |
| 이름 | 하는 일 |
|---|---|
next-steps | 턴이 끝나면 다음 할 일을 최대 4개 제안, 체크박스로 골라 입력창에 채움 (mods/README.md) |
context-bar | 입력창 위에 컨텍스트 창 사용량을 카테고리별 색 막대와 비율로 표시 (mods/README.md) |
agent-monitor | 서브에이전트의 상태·경과 시간·도구 호출 수·토큰을 사이드 패널에 실시간 표시 (mods/README.md) |
프롬프트를 복사해 {빈칸}만 채워 씁니다.
| 상황 | 프롬프트 |
|---|---|
| 기능 개발 (설계 → 구현 → 테스트) | prompts/feature-development.md |
| 커밋·배포 전 코드 리뷰 | prompts/code-review.md |
| 화면 캡처로 API 응답 설계 | prompts/screen-spec.md |
{증상 — 무엇이 언제부터 어떻게}. 순서대로 진행해줘.
1) debugger로 재현 경로를 잡고 문제 구간을 file:line으로 특정
2) performance-optimizer로 {관찰 지점}을 포함해 병목을 우선순위로 진단
3) architecture-expert 관점에서 구조적 개선안을 트레이드오프로 비교
셋 다 진단만 하고 코드는 고치지 마. 마지막에 안 A/B/C로 정리해줘.
./install.sh # 대화형: 대상·유형·항목을 골라 설치
./install.sh list # 설치 상태 확인
./install.sh uninstall # 우리 심링크만 제거
스크립트에서 쓰거나 세부 옵션(--type, --project, --force, --dry-run 등)이 필요하면 ./install.sh -h를 본다.
hooks/register.tsx 151 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot, Warn } from '../types'
5import type { Cell } from './bar'
6import {
7 barWidth,
8 capacityLabel,
9 DEFAULT_WARN,
10 describe,
11 extras,
12 formatTokens,
13 HELP,
14 layout,
15 normalizeWarn,
16 parseWarn,
17 percentColor,
18 toSnapshot,
19 warnPercent,
20} from './bar'
21
22const enabled = atom({ plugin: 'context-bar', key: 'enabled' } as const, true)
23const warn = atom({ plugin: 'context-bar', key: 'warn' } as const, DEFAULT_WARN as Warn)
24// 마지막 측정값. 측정 전·압축 직후·꺼짐이면 null.
25const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null as Snapshot | null)
26
27const STORE_ENABLED = 'enabled'
28const STORE_WARN = 'warn'
29
30const GLYPH = { used: '█', free: '░', buffer: '·' } as const
31
32function cellStyle(cell: Cell) {
33 if (cell.kind === 'used') return { color: cell.color }
34 if (cell.kind === 'free') return { color: 'inactive' }
35 return { dimColor: true }
36}
37
38// summary는 로컬 추정이라 호출 비용이 없다.
39async function measure($: EngineInterface) {
40 try {
41 const { context } = await $.session.usage({ breakdown: 'summary' })
42 const breakdown = context.breakdown
43 if (breakdown) await update($, snapshot, prev => toSnapshot(breakdown, prev))
44 } catch (error) {
45 $.ui.log(`context-bar: 컨텍스트 사용량을 읽지 못했어요 (${String(error)})`)
46 }
47}
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 const storedEnabled = await $.store.get(STORE_ENABLED)
52 await update($, enabled, () => storedEnabled !== false)
53 const storedWarn = normalizeWarn(await $.store.get(STORE_WARN))
54 await update($, warn, () => storedWarn)
55 await update($, snapshot, () => null)
56 await $.command.register({
57 name: 'context-bar',
58 description: '컨텍스트 막대: on/off, warn <노랑> <빨강>, 인자 없으면 현재 설정',
59 argumentHint: '[on|off|warn]',
60 })
61 return next(e)
62 })
63
64 // /clear·resume에는 session.start가 오지 않는다. 대화가 바뀌었으니 이전 측정값을 버린다.
65 on('session.end', async ($, e, next) => {
66 if (e.reason === 'clear' || e.reason === 'resume') await update($, snapshot, () => null)
67 return next(e)
68 })
69
70 // 메인 턴이 끝날 때마다 온다. 서브에이전트 턴에는 오지 않는다.
71 on('session.measure', async ($, e, next) => {
72 const done = await next(e)
73 if (e.changed.includes('context') && (await read($, enabled))) await measure($)
74 return done
75 })
76
77 // 압축된 대화는 다음 응답이 올 때까지 측정값이 없다.
78 on('session.compact', async ($, e, next) => {
79 const result = await next(e)
80 const isApplied = e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result)
81 if (isApplied) await update($, snapshot, () => null)
82 return result
83 })
84
85 on('command.run', { command: 'context-bar' }, async ($, e) => {
86 const [sub = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
87
88 if (sub === '') {
89 return { text: describe(await read($, enabled), await read($, warn), await read($, snapshot)) }
90 }
91 if ((sub === 'on' || sub === 'off') && rest.length === 0) {
92 const isOn = sub === 'on'
93 await update($, enabled, () => isOn)
94 await $.store.set(STORE_ENABLED, isOn)
95 // 꺼진 동안 측정하지 않으므로, 다시 켰을 때 오래된 값을 보여주지 않는다.
96 if (!isOn) await update($, snapshot, () => null)
97 return { text: isOn ? '컨텍스트 막대를 켰어요. 다음 턴부터 다시 측정해요.' : '컨텍스트 막대를 껐어요.' }
98 }
99 if (sub === 'warn') {
100 const parsed = parseWarn(rest.join(' '))
101 if (typeof parsed === 'string') return { text: parsed }
102 await update($, warn, () => parsed)
103 await $.store.set(STORE_WARN, parsed)
104 return { text: `경고 기준: ${parsed.yellow}% 노랑 · ${parsed.red}% 빨강` }
105 }
106 return { text: HELP }
107 })
108
109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 const above = await next(e)
111 if (e.props.hasSurvey || !(await read($, enabled))) return above
112
113 const { Box, Text } = $.ui.resolve(e)
114 const snap = await read($, snapshot)
115 if (snap === null) {
116 return (
117 <Box flexDirection="column">
118 {above}
119 <Text key="context-bar" dimColor>컨텍스트 측정 전</Text>
120 </Box>
121 )
122 }
123
124 const width = barWidth(e.props.bodyColumns)
125 const cells = layout(snap.segments, width)
126 const color = percentColor(warnPercent(snap), await read($, warn))
127 const { top, delta } = extras(snap, e.props.bodyColumns, width)
128 return (
129 <Box flexDirection="column">
130 {above}
131 <Box key="context-bar" flexDirection="row">
132 {cells.map((cell, i) => (
133 <Text key={`cell-${i}`} {...cellStyle(cell)}>
134 {GLYPH[cell.kind].repeat(cell.cells)}
135 </Text>
136 ))}
137 <Text> </Text>
138 <Text key="percent" {...(color ? { color } : {})}>
139 {`${snap.percent}%`}
140 </Text>
141 <Text dimColor>{` · ${capacityLabel(snap)}`}</Text>
142 {top && <Text dimColor> · </Text>}
143 {top && <Text key="top" color={top.color}>{top.name}</Text>}
144 {top && <Text dimColor>{` ${formatTokens(top.tokens)}`}</Text>}
145 {delta && <Text dimColor>{` · ${delta}`}</Text>}
146 </Box>
147 </Box>
148 )
149 })
150}
151hooks/bar.ts 157 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Segment, Snapshot, Warn } from '../types'
4
5export const DEFAULT_WARN: Warn = { yellow: 70, red: 85 }
6export const HELP = '사용법: /context-bar [on|off|warn <노랑> <빨강>]'
7const WARN_RULE = '경고 기준은 0 < 노랑 < 빨강 ≤ 100 인 정수여야 해요. 예: /context-bar warn 70 85'
8
9const MIN_WIDTH = 10
10const MAX_WIDTH = 30
11// 막대 뒤 " 104% · 압축까지 999k" 글자와 여유 칸.
12const LABEL_COLUMNS = 24
13const ORDER = { used: 0, free: 1, buffer: 2 } as const
14
15export type Cell = Segment & { cells: number }
16
17export function barWidth(columns: number): number {
18 return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, columns - LABEL_COLUMNS))
19}
20
21// prev는 직전 측정값. 없으면(첫 측정·압축 직후) 증가량을 남기지 않는다.
22export function toSnapshot(b: SessionContextBreakdown, prev: Snapshot | null): Snapshot {
23 const segments: Segment[] = b.categories
24 .filter(c => c.kind !== 'deferred')
25 .map(c => ({ kind: c.kind as Segment['kind'], name: c.name, color: c.color, tokens: c.tokens }))
26 // 압축 버퍼는 남은 공간 뒤, 막대 끝에 둔다. 같은 kind끼리는 받은 순서를 지킨다.
27 .sort((x, y) => ORDER[x.kind] - ORDER[y.kind])
28 return {
29 segments,
30 totalTokens: b.totalTokens,
31 maxTokens: b.rawMaxTokens,
32 percent: b.percentage,
33 compactAt: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
34 delta: prev === null ? null : b.totalTokens - prev.totalTokens,
35 }
36}
37
38function largest(ns: number[]): number {
39 let best = 0
40 ns.forEach((n, i) => {
41 if (n > ns[best]!) best = i
42 })
43 return best
44}
45
46export function layout(segments: Segment[], width: number): Cell[] {
47 const shown = segments.filter(s => s.tokens > 0)
48 const total = shown.reduce((sum, s) => sum + s.tokens, 0)
49 if (total === 0) return []
50
51 // 최대 나머지 방식: 내림한 뒤 남은 칸을 소수부가 큰 구간부터 하나씩 준다.
52 // 구간마다 1칸씩 보장하면 작은 구간이 많을 때 사용 칸이 실제보다 부푼다.
53 const exact = shown.map(s => (s.tokens / total) * width)
54 const cells = exact.map(Math.floor)
55 const byRemainder = exact.map((_, i) => i).sort((a, b) => (exact[b]! % 1) - (exact[a]! % 1))
56 let left = width - cells.reduce((sum, n) => sum + n, 0)
57 for (const i of byRemainder) {
58 if (left === 0) break
59 cells[i] = cells[i]! + 1
60 left -= 1
61 }
62
63 // 쓰고 있는 게 있으면 1칸은 보이게 한다. 그 칸은 가장 큰 구간에서 뺀다.
64 const used = shown.flatMap((s, i) => (s.kind === 'used' ? [i] : []))
65 if (used.length > 0 && used.every(i => cells[i] === 0)) {
66 const give = used.reduce((a, b) => (shown[b]!.tokens > shown[a]!.tokens ? b : a))
67 const take = largest(cells)
68 cells[take] = cells[take]! - 1
69 cells[give] = 1
70 }
71 return shown.map((s, i) => ({ ...s, cells: cells[i]! })).filter(c => c.cells > 0)
72}
73
74// 경고 색의 기준 비율. 자동 압축이 켜져 있으면 압축 시점 대비, 꺼져 있으면 창 대비.
75export function warnPercent(snap: Snapshot): number {
76 if (snap.compactAt === null || snap.compactAt <= 0) return snap.percent
77 return Math.round((snap.totalTokens / snap.compactAt) * 100)
78}
79
80export function capacityLabel(snap: Snapshot): string {
81 if (snap.compactAt === null) return `${formatTokens(snap.totalTokens)} / ${formatTokens(snap.maxTokens)}`
82 return `압축까지 ${formatTokens(Math.max(0, snap.compactAt - snap.totalTokens))}`
83}
84
85export function formatDelta(n: number | null): string | null {
86 if (n === null || n === 0) return null
87 return `${n > 0 ? '+' : '-'}${formatTokens(Math.abs(n))}`
88}
89
90export function largestUsed(segments: Segment[]): Segment | undefined {
91 return segments
92 .filter(s => s.kind === 'used' && s.tokens > 0)
93 .reduce<Segment | undefined>((best, s) => (best === undefined || s.tokens > best.tokens ? s : best), undefined)
94}
95
96// 터미널 칸 수. 한글은 두 칸을 차지한다.
97export function displayWidth(text: string): number {
98 return [...text].reduce((n, ch) => n + (/[\u1100-\u11ff\u3130-\u318f\uac00-\ud7a3]/.test(ch) ? 2 : 1), 0)
99}
100
101export type Extras = { top?: Segment; delta?: string }
102
103// 막대와 비율·용량 뒤에 남는 칸에 덧붙일 것. 좁으면 증가량, 그다음 카테고리 이름 순으로 뺀다.
104export function extras(snap: Snapshot, columns: number, barCells: number): Extras {
105 let room = columns - barCells - displayWidth(` ${snap.percent}% · ${capacityLabel(snap)}`)
106 const top = largestUsed(snap.segments)
107 if (top === undefined) return {}
108 const topWidth = displayWidth(` · ${top.name} ${formatTokens(top.tokens)}`)
109 if (room < topWidth) return {}
110 room -= topWidth
111
112 const delta = formatDelta(snap.delta)
113 if (delta === null || room < displayWidth(` · ${delta}`)) return { top }
114 return { top, delta }
115}
116
117export function percentColor(percent: number, warn: Warn): 'error' | 'warning' | undefined {
118 if (percent >= warn.red) return 'error'
119 if (percent >= warn.yellow) return 'warning'
120 return undefined
121}
122
123export function formatTokens(n: number): string {
124 if (n < 1_000) return String(n)
125 if (n < 999_500) return `${Math.round(n / 1_000)}k`
126 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
127}
128
129export function parseWarn(text: string): Warn | string {
130 const parts = text.trim().split(/\s+/)
131 if (parts.length !== 2 || !parts.every(p => /^\d+$/.test(p))) return HELP
132 const [yellow, red] = parts.map(Number) as [number, number]
133 if (yellow <= 0 || yellow >= red || red > 100) return WARN_RULE
134 return { yellow, red }
135}
136
137export function normalizeWarn(stored: unknown): Warn {
138 if (typeof stored !== 'object' || stored === null) return DEFAULT_WARN
139 const { yellow, red } = stored as Partial<Warn>
140 const parsed = parseWarn(`${yellow} ${red}`)
141 return typeof parsed === 'string' ? DEFAULT_WARN : parsed
142}
143
144// 앱이 명령 이름(context-bar:)을 앞에 붙이므로 여기서는 쓰지 않는다.
145export function describe(enabled: boolean, warn: Warn, snap: Snapshot | null): string {
146 const head = `${enabled ? '켜짐' : '꺼짐'} · 경고 ${warn.yellow}% / ${warn.red}% · 마지막 측정`
147 if (snap === null) return `${head} 아직 없음`
148
149 const compact = snap.compactAt === null ? '' : ` · ${capacityLabel(snap)}`
150 const parts = snap.segments
151 .filter(s => s.kind === 'used' && s.tokens > 0)
152 .sort((a, b) => b.tokens - a.tokens)
153 .map(s => `${s.name} ${formatTokens(s.tokens)}`)
154 const makeup = parts.length === 0 ? '' : `\n구성: ${parts.join(' · ')}`
155 return `${head} ${snap.percent}% (${formatTokens(snap.totalTokens)} / ${formatTokens(snap.maxTokens)})${compact}${makeup}`
156}
157types/index.d.ts 37 lines1// 막대 한 구간. breakdown의 categories에서 deferred(창 밖)를 뺀 것.
2export type Segment = {
3 kind: 'used' | 'free' | 'buffer'
4 // /context가 쓰는 행 이름 (Messages, System prompt, ...)
5 name: string
6 // /context가 쓰는 테마 색 키 (promptBorder, permission, inactive, ...)
7 color: string
8 tokens: number
9}
10
11// 마지막 측정값. 그리는 데 필요한 값만 남긴다.
12export type Snapshot = {
13 segments: Segment[]
14 totalTokens: number
15 // breakdown.rawMaxTokens
16 maxTokens: number
17 // breakdown.percentage (rawMaxTokens 대비, 넘치면 100 초과)
18 percent: number
19 // 자동 압축이 실행되는 토큰 수. 자동 압축이 꺼져 있으면 null.
20 compactAt: number | null
21 // 직전 측정 대비 증가량. 첫 측정·압축 직후에는 null.
22 delta: number | null
23}
24
25// 비율 숫자 색 기준(%)
26export type Warn = { yellow: number; red: number }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'context-bar': {
31 enabled: boolean
32 warn: Warn
33 snapshot: Snapshot | null
34 }
35 }
36}
37