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가 어떻게 사용량을 조회하는지 알아내기 위해 공식 레포를 분석했습니다:

  1. 인증 정보는 ~/.codex/auth.json에 저장
  2. 모델 설정은 ~/.codex/config.toml에 저장
  3. 사용량 조회는 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.jsonstatusLine 설정이 있는지 확인하세요. 없다면 /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"로 변경하세요.

13. 참고 자료