Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
Claude Code 사용 중 컨텍스트 윈도우와 API 제한을 한눈에 파악하기 어려웠습니다. 이 문제를 해결하기 위해 상태줄 플러그인을 개발했고, 위젯 패턴, 3단계 캐싱, 국제화 등의 기술적 결정 과정을 공유합니다.
1. 문제 상황
Claude Code를 사용하다 보면 몇 가지 불편한 점이 있습니다:
- 컨텍스트 윈도우 사용량을 모름: 대화가 길어지면 갑자기 "context window exceeded" 에러가 발생
- API 제한에 걸림: 5시간 제한이 언제 리셋되는지 모르고 작업하다가 갑자기 멈춤
- 비용 추적 어려움: 세션별로 얼마나 사용했는지 파악 불가
- 여러 정보를 한눈에 보고 싶음: 현재 모델, 브랜치, 진행 상황 등
기존에는 /usage 명령어로 확인할 수 있지만, 매번 입력해야 하고 작업 흐름이 끊깁니다.
# 기존 방식: 매번 수동으로 확인
/usage
→ 화면 전환 → 정보 확인 → 다시 작업으로 복귀
원하는 것: 상태줄에 항상 표시되어 한눈에 파악
2. 해결 방법: 상태줄 플러그인
Claude Code는 statusLine 설정을 통해 커스텀 상태줄을 지원합니다. 이를 활용해 실시간 정보를 표시하는 플러그인을 만들었습니다.
최종 결과물
Compact 모드 (1줄):
🤖 Opus │ ████████░░ 80% │ 160K/200K │ $0.25 │ 5h: 42% (2h30m) │ 7d: 69%
Normal 모드 (2줄):
🤖 Opus │ ████████░░ 80% │ 160K/200K │ $0.25 │ 5h: 42% (2h30m) │ 7d: 69%
📁 my-project (main) │ ⏱ 45m │ ✓ 3/5
Detailed 모드 (4줄):
🤖 Opus │ ████████░░ 80% │ 160K/200K │ $0.25 │ 5h: 42% (2h30m) │ 7d: 69%
📁 my-project (main) │ ⏱ 45m │ 🔥 1.2K/m │ ⏳ 3h │ ✓ 3/5
CLAUDE.md: 2 │ ⚙️ 12 done │ 🤖 Agent: 1 │ Cache: 45%
🔶 Codex: o3 │ 5h: 10% │ 7d: 5%
3. 아키텍처 설계
위젯 시스템
각 정보를 독립적인 "위젯"으로 분리했습니다:
// 위젯 인터페이스
interface Widget<T extends WidgetData> {
id: WidgetId;
name: string;
getData(ctx: WidgetContext): Promise<T | null>;
render(data: T, ctx: WidgetContext): string;
}
이 설계의 장점:
- 독립성: 각 위젯이 자체 데이터 fetching과 렌더링 담당
- 확장성: 새 위젯 추가가 간단
- 안정성: 한 위젯 실패가 전체에 영향 없음 (graceful degradation)
구현된 위젯 목록
| Widget ID | 데이터 소스 | 설명 |
|---|---|---|
model |
stdin | 모델명 (Opus, Sonnet, Haiku) |
context |
stdin | 컨텍스트 사용량 프로그레스 바 |
cost |
stdin | 세션 누적 비용 |
rateLimit5h |
API | 5시간 제한 + 리셋 시간 |
rateLimit7d |
API | 7일 제한 (Max 플랜) |
projectInfo |
stdin + git | 프로젝트명 + 브랜치 |
sessionDuration |
file | 세션 지속 시간 |
todoProgress |
transcript | 할일 진행률 |
burnRate |
stdin + session | 분당 토큰 소비량 |
cacheHit |
stdin | 캐시 적중률 |
depletionTime |
API + session | 제한 도달 예상 시간 |
codexUsage |
Codex API | OpenAI Codex CLI 사용량 |
위젯 구현 예시: context 위젯
// scripts/widgets/context.ts
import { Widget, WidgetContext, ContextData } from '../types.js';
import { createProgressBar } from '../utils/progress-bar.js';
import { formatTokens } from '../utils/formatters.js';
export const contextWidget: Widget<ContextData> = {
id: 'context',
name: 'Context Usage',
async getData(ctx: WidgetContext): Promise<ContextData | null> {
const { context_window } = ctx.stdin;
if (!context_window?.current_usage) return null;
const { input_tokens, output_tokens } = context_window.current_usage;
const totalTokens = input_tokens + output_tokens;
const contextSize = context_window.context_window_size;
return {
inputTokens: input_tokens,
outputTokens: output_tokens,
totalTokens,
contextSize,
percentage: (totalTokens / contextSize) * 100,
};
},
render(data: ContextData, ctx: WidgetContext): string {
const bar = createProgressBar(data.percentage, 10); // ← 10칸 프로그레스 바
const pct = `${Math.round(data.percentage)}%`;
const tokens = `${formatTokens(data.totalTokens)}/${formatTokens(data.contextSize)}`;
return `${bar} ${pct} │ ${tokens}`;
},
};
프로그레스 바 색상 로직
// scripts/utils/progress-bar.js
import { colors } from './colors.js';
export function createProgressBar(percentage: number, width: number): string {
const filled = Math.round((percentage / 100) * width);
const empty = width - filled;
// 색상 결정: 0-50% 초록, 51-80% 노랑, 81-100% 빨강
let color: string;
if (percentage <= 50) {
color = colors.green;
} else if (percentage <= 80) {
color = colors.yellow;
} else {
color = colors.red;
}
const filledBar = color + '█'.repeat(filled) + colors.reset;
const emptyBar = colors.dim + '░'.repeat(empty) + colors.reset;
return filledBar + emptyBar;
}
4. API 연동과 캐싱
OAuth API로 Rate Limit 조회
Claude Code는 내부적으로 OAuth 토큰을 사용합니다. 이를 활용해 rate limit 정보를 조회합니다:
// scripts/utils/api-client.ts
export async function fetchUsageLimits(token: string): Promise<UsageLimits> {
const response = await fetch('https://api.anthropic.com/oauth/usage', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
3단계 캐싱 시스템
API 호출을 최소화하기 위해 3단계 캐싱을 구현했습니다:
// 1. 메모리 캐시 (가장 빠름)
const memoryCache = new Map<string, CacheEntry>();
// 2. 파일 캐시 (프로세스 재시작 시에도 유지)
const fileCachePath = `~/.cache/claude-dashboard/cache-${tokenHash}.json`;
// 3. API 호출 (캐시 미스 시)
async function getCachedUsageLimits(token: string): Promise<UsageLimits> {
const hash = hashToken(token);
// 1단계: 메모리 캐시 확인
const memCached = memoryCache.get(hash);
if (memCached && !isExpired(memCached)) {
return memCached.data;
}
// 2단계: 파일 캐시 확인
const fileCached = await readFileCache(hash);
if (fileCached && !isExpired(fileCached)) {
memoryCache.set(hash, fileCached); // 메모리에도 저장
return fileCached.data;
}
// 3단계: API 호출
const data = await fetchUsageLimits(token);
const entry = { data, timestamp: Date.now() };
memoryCache.set(hash, entry);
await writeFileCache(hash, entry);
return data;
}
멀티 계정 지원
토큰을 해시하여 계정별로 캐시를 분리합니다:
function hashToken(token: string): string {
return crypto.createHash('sha256')
.update(token)
.digest('hex')
.slice(0, 16); // 16자리만 사용
}
// 캐시 파일: ~/.cache/claude-dashboard/cache-abc123def456.json
Codex CLI 연동 (v1.4.0)
Claude Code와 OpenAI Codex CLI를 함께 사용하는 경우, 두 도구의 rate limit을 왔다갔다 확인하는 게 번거로웠습니다. 한 화면에서 모니터링하기 위해 Codex 위젯을 추가했습니다.
Codex repo 역분석
Codex CLI가 어떻게 사용량을 조회하는지 알아내기 위해 공식 레포를 분석했습니다:
- 인증 정보는
~/.codex/auth.json에 저장 - 모델 설정은
~/.codex/config.toml에 저장 - 사용량 조회는 ChatGPT 백엔드 API 사용
auth.json 구조
{
"tokens": {
"access_token": "eyJhbGci...",
"account_id": "abc123..."
}
}
API 엔드포인트
// scripts/utils/codex-client.ts
const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
method: 'GET',
headers: {
'Authorization': `Bearer ${auth.accessToken}`,
'ChatGPT-Account-Id': auth.accountId,
'User-Agent': `claude-dashboard/${VERSION}`,
},
});
응답 구조
interface CodexApiResponse {
plan_type: string; // "plus", "pro" 등
rate_limit: {
primary_window: { // 5시간 제한
used_percent: number;
reset_at: number; // Unix timestamp
} | null;
secondary_window: { // 7일 제한
used_percent: number;
reset_at: number;
} | null;
};
}
Graceful Degradation
Codex CLI가 설치되지 않은 환경에서는 ~/.codex/auth.json 파일이 없으므로, 위젯이 자동으로 숨겨집니다:
export async function isCodexInstalled(): Promise<boolean> {
try {
await stat(CODEX_AUTH_PATH);
return true;
} catch {
return false; // 파일 없으면 미설치로 판단
}
}
Claude API와 동일하게 메모리 캐싱과 요청 중복 방지를 적용했습니다.
5. 디스플레이 모드 시스템
사용자가 원하는 정보량에 따라 3가지 프리셋을 제공합니다:
// scripts/types.ts
export const DISPLAY_PRESETS = {
compact: [
['model', 'context', 'cost', 'rateLimit5h', 'rateLimit7d', 'rateLimit7dSonnet'],
],
normal: [
['model', 'context', 'cost', 'rateLimit5h', 'rateLimit7d', 'rateLimit7dSonnet'],
['projectInfo', 'sessionDuration', 'burnRate', 'todoProgress'],
],
detailed: [
['model', 'context', 'cost', 'rateLimit5h', 'rateLimit7d', 'rateLimit7dSonnet'],
['projectInfo', 'sessionDuration', 'burnRate', 'depletionTime', 'todoProgress'],
['configCounts', 'toolActivity', 'agentStatus', 'cacheHit'],
['codexUsage'],
],
};
설계 원칙: Additive Approach
각 모드가 이전 모드를 확장하는 방식입니다:
compact: 핵심 정보만 (1줄)normal: compact + 프로젝트/세션 정보 (2줄)detailed: normal + 상세 메트릭 (4줄)
커스텀 모드
고급 사용자를 위해 완전한 커스터마이징도 지원합니다:
{
"displayMode": "custom",
"lines": [
["model", "context", "rateLimit5h"],
["projectInfo", "todoProgress"]
]
}
6. 국제화 (i18n)
한국어와 영어를 지원합니다:
// locales/ko.json
{
"labels": {
"5h": "5시간",
"7d": "7일"
},
"time": {
"hours": "시간",
"minutes": "분"
},
"widgets": {
"burnRate": "소비율",
"toLimit": "제한까지"
}
}
// locales/en.json
{
"labels": {
"5h": "5h",
"7d": "7d"
},
"time": {
"hours": "h",
"minutes": "m"
},
"widgets": {
"burnRate": "burn",
"toLimit": "to limit"
}
}
시스템 언어를 자동 감지하거나 명시적으로 설정할 수 있습니다:
function detectLanguage(): 'en' | 'ko' {
const lang = process.env.LANG || process.env.LC_ALL || '';
return lang.startsWith('ko') ? 'ko' : 'en';
}
7. Graceful Degradation
모든 위젯은 실패 시 null을 반환하도록 설계했습니다:
async function renderLine(widgets: WidgetId[], ctx: WidgetContext): Promise<string> {
const results = await Promise.all(
widgets.map(async (id) => {
try {
const widget = getWidget(id);
const data = await widget.getData(ctx);
if (!data) return null; // ← 데이터 없으면 숨김
return widget.render(data, ctx);
} catch {
return null; // ← 에러 시에도 숨김
}
})
);
return results.filter(Boolean).join(' │ ');
}
예시: Codex CLI가 설치되지 않은 환경에서는 codexUsage 위젯이 자동으로 숨겨집니다.
8. 플러그인 설치 경험 개선
버전 동적 탐색
플러그인 버전이 업데이트되어도 재설정 없이 작동하도록 동적 경로 탐색을 구현했습니다:
# setup.md에서 사용하는 명령어
PLUGIN_PATH=$(ls -d ~/.claude/plugins/cache/claude-dashboard/claude-dashboard/*/dist/index.js | sort -V | tail -1)
Before (문제점):
{
"command": "node ~/.claude/plugins/.../1.3.0/dist/index.js"
}
// 버전 업데이트 시 경로 수동 변경 필요
After (해결):
# 항상 최신 버전 자동 탐색
jq --arg path "$(ls -d ~/.claude/plugins/.../*/dist/index.js | sort -V | tail -1)" \
'.statusLine.command = "node " + $path' settings.json
9. 개발 중 배운 점
esbuild 번들링 주의점
TypeScript 소스 파일과 빌드 결과물이 다른 경우가 있었습니다:
// types.ts (소스) - 수정됨
detailed: [
['widget1'],
['widget2'], // ← 마지막
]
// dist/index.js (빌드 결과) - 반영 안 됨
detailed: [
['widget2'], // ← 순서가 다름
['widget1'],
]
원인: 같은 상수를 두 파일에서 정의하고 있었음
// types.ts
export const DISPLAY_PRESETS = { ... };
// widgets/index.ts
const presets = { ... }; // ← 별도 정의 (동기화 안 됨)
해결: 단일 소스로 통합
// widgets/index.ts
import { DISPLAY_PRESETS } from '../types.js';
export function getLines(config: Config): WidgetId[][] {
return DISPLAY_PRESETS[config.displayMode] || DISPLAY_PRESETS.compact;
}
Git Branch 감지
execFileSync를 사용해 git 브랜치를 안전하게 가져옵니다:
import { execFileSync } from 'node:child_process';
function getGitBranch(cwd: string): string | undefined {
try {
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// 변경사항 있으면 * 추가
const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
const isDirty = status.length > 0;
return isDirty ? `${branch}*` : branch;
} catch {
return undefined; // git 저장소가 아닌 경우
}
}
10. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
| Widget Pattern | 각 정보를 독립적인 컴포넌트로 분리 |
| Graceful Degradation | 실패 시 숨김 처리로 전체 안정성 확보 |
| Multi-tier Caching | 메모리 → 파일 → API 순차 조회 |
| Additive Display | 상위 모드가 하위 모드를 포함 |
| Dynamic Path Resolution | 버전 하드코딩 없이 자동 탐색 |
11. 베스트 프랙티스
Claude Code 플러그인 개발 체크리스트
- [ ]
dist/index.js를 커밋에 포함 (사용자가 빌드할 필요 없음) - [ ] API 호출에 캐싱 적용 (rate limit 방지)
- [ ] 모든 외부 호출에 try-catch 적용
- [ ] 실패 시
null반환으로 graceful degradation - [ ] i18n 지원 (최소 영어 기본)
- [ ] 버전 하드코딩 피하기
상태줄 설계 원칙
- [ ] 핵심 정보를 왼쪽에 배치
- [ ] 색상으로 상태 표현 (초록/노랑/빨강)
- [ ] 숫자는 읽기 쉽게 포맷 (160000 → 160K)
- [ ] 시간은 축약형 사용 (2시간 30분 → 2h30m)
12. FAQ
Q: 상태줄이 표시되지 않습니다.
A: /plugin list로 설치 확인 후, ~/.claude/settings.json에 statusLine 설정이 있는지 확인하세요. 없다면 /claude-dashboard:setup을 다시 실행하세요.
Q: Rate limit에 ⚠️가 표시됩니다.
A: API 토큰 만료 가능성이 있습니다. Claude Code를 재시작하거나 다시 로그인하세요.
Q: 커스텀 위젯 순서가 원하는 대로 안 됩니다.
A: Interactive 모드 대신 Direct 모드를 사용하세요:
/claude-dashboard:setup custom auto max "widget1,widget2|widget3"
Q: Codex 위젯이 표시되지 않습니다.
A: Codex CLI가 설치되지 않은 환경에서는 자동으로 숨겨집니다. ~/.codex/auth.json 파일이 있어야 합니다.
Q: 한국어로 변경하고 싶습니다.
A: /claude-dashboard:setup compact ko 또는 설정 파일에서 "language": "ko"로 변경하세요.