Claude Code mod 직접 만들기: 원화 요금 미터기를 빈 폴더부터 공유까지

Claude Code mod 직접 만들기: 원화 요금 미터기를 빈 폴더부터 공유까지

핵심 요약

  • 파일 네 개(훅 모듈 128줄)로 원화 요금 미터기 mod를 만들었습니다. 프롬프트 위 띠에 세션 비용을 원화로 띄우고, /receipt로 턴마다 남긴 영수증 창을 엽니다. macOS의 Claude Code 2.1.296에서 직접 돌린 결과입니다.
  • 검사는 세 겹으로 하는 편이 좋습니다. claude plugin validate는 엔진이 읽는 모양을, tsc는 타입을, claude plugin test는 동작을 봅니다. validate와 test를 통과한 코드에서 tsc가 오류 11개를 더 찾았습니다.
  • --plugin-dir로 띄우면 저장할 때마다 핫 리로드됩니다. 모듈 변수는 초기화되지만 $.state 값은 남고, 저장한 코드가 깨지면 이전 버전이 계속 돕니다.
  • 실제 세션에서 미터와 /cost가 1~2센트 달랐습니다. 턴이 끝난 뒤 생긴 비용이 빠졌기 때문으로 보입니다. /receipt를 열 때 $.session.usage()를 다시 읽게 고치자 ₩473으로 /cost의 $0.3379와 맞았습니다.
  • 공유는 marketplace.json 하나면 됩니다. 저장소에 올리면 받는 사람은 /plugin install won-meter --marketplace <owner>/<repo> 한 줄로 설치할 수 있습니다. 필자는 저장소에 올려 설치까지 해 보지는 않았습니다.

목차

무엇을 만드나요?

세션 비용을 원화로 보여 주는 미터기와, 턴마다 얼마가 들었는지 적은 영수증 창을 만듭니다. 이름은 won-meter로 정했습니다. 동작은 세 가지입니다.

  1. 띠(band): 턴이 끝날 때마다 프롬프트 바로 위에 🚕 ₩546 ($0.39) · 지난 턴 +₩67 · 5초 · 도구 1회 · 컨텍스트 6% [ 영수증 ] 한 줄을 그립니다.
  2. 영수증 창(pane): /receipt를 치거나 띠의 버튼을 누르면 턴별 금액, 걸린 시간, 도구 호출 수, 모델이 적힌 창이 열립니다.
  3. 설정: 환율은 /config에서 바꿀 수 있고 기본값은 1,400원입니다.

이 주제를 고른 계기는 두 영상입니다. 코드팩토리는 mod의 사례 10가지 가운데 하나로 입력창 위에 컨텍스트 사용량을 보여 주는 mod를 소개했고, 원하는 것을 말로 설명하면 Claude가 mod를 써 준다고 정리했습니다(코드팩토리, Claude Code Mods 설명, 10가지 사용 사례, 2026-10-06). 개발동생은 택시 콘셉트로 요금 미터기와 영수증 창을 만들어 devbrothers-mods(MIT)로 공개했습니다. 이 저장소의 미터기는 일·주 단위 집계와 90일 장부까지 갖춘 완성품입니다. 이 글의 mod는 그 코드를 보지 않고 공식 API만으로 처음부터 짠 훨씬 작은 연습용입니다. 대신 짜는 과정에서 무엇을 확인하고 어디서 막혔는지를 남기는 데 집중했습니다.

mod가 무엇이고 설정 훅, 스킬, MCP와 어떻게 다른지, 공개된 mod의 보안 문제는 Claude Code Mods 구조와 10가지 활용에 정리해 두었습니다. 이 글은 그 내용을 다시 설명하지 않고 바로 만들기로 들어갑니다.

상태줄 스크립트가 아니라 mod로 만드는 이유

비용만 띄우려면 상태줄로도 됩니다. 상태줄은 설정에 적은 셸 스크립트가 세션 정보를 JSON으로 받아 출력하는 방식이고, 그 JSON에 cost.total_cost_usd(API 정가로 클라이언트에서 계산한 추정 비용)가 들어 있습니다(Customize your status line). mod를 고른 이유는 상태줄이 못 하는 세 가지가 필요했기 때문입니다. 턴이 끝나는 순간(turn.complete)에 맞춰 영수증 한 줄을 남기는 것, 버튼과 창을 그리는 것, /receipt 같은 명령을 Claude 턴 없이 바로 처리하는 것입니다.

mod가 그릴 수 있는 자리는 공식 화면 지도에 나와 있습니다. 이 글의 mod는 그중 프롬프트 위 띠(Band above the prompt)와 오른쪽 창(Pane) 두 곳을 씁니다.

Claude Code 터미널 화면에서 mod가 그릴 수 있는 자리를 표시한 지도. 오른쪽 사이드바 창, 오른쪽 위 토스트, 트랜스크립트의 로그 줄, 프롬프트 위 띠 영역, 프롬프트 아래 상태줄이 mod가 더하는 자리이고, 메시지, 도구 호출 행, 스피너는 다시 그릴 수 있는 자리, 프롬프트는 Claude Code 고유 영역이다
mod가 그리는 자리. 이 글의 미터기는 프롬프트 위 띠(Band above the prompt)에, 영수증은 오른쪽 창(Pane)에 그립니다. 전체 화면 레이아웃이 아니거나 터미널이 좁으면 창이 프롬프트 위에 붙습니다. 이전 글에서도 쓴 공식 도식입니다. 출처: Anthropic, Draw in the interface with a mod

전체 순서는 다음 그림과 같습니다. 3~6단계는 한 번에 끝나지 않고 고칠 때마다 돌아옵니다.

mod 하나를 만드는 일곱 단계
won-meter를 만든 순서. 실제로는 6단계에서 금액 차이를 발견하고 2단계 코드를 고친 뒤 3~6단계를 다시 돌았습니다. 필자 작성

1단계: 폴더와 매니페스트

mod는 매니페스트, 훅 목록, 훅 모듈 세 파일로 시작하고, $.state에 값을 두려면 상태 계약 파일 하나가 더 필요합니다. 완성된 폴더는 다음과 같습니다.

won-meter/
├── .claude-plugin/
│   ├── plugin.json        # 이름, 버전, 설정 항목, 상태 계약 위치
│   └── marketplace.json   # 7단계에서 추가
├── hooks/
│   ├── hooks.json         # { "modules": ["./register.tsx"] }
│   └── register.tsx       # 훅 모듈 (128줄)
├── types/
│   └── index.d.ts         # $.state에 둘 값의 타입
├── tests/
│   └── won-meter.test.ts  # claude plugin test가 돌리는 테스트
└── README.md

매니페스트에는 환율 설정을 userConfig로 넣었습니다. type, title, description은 필수이고, 숫자 항목에는 min, max를 줄 수 있습니다. 이렇게 선언한 항목은 /config에 한 줄로 나타나고, 값은 settings.json의 pluginConfigs에 저장됩니다(Plugin manifest reference).

{
  "name": "won-meter",
  "version": "0.1.0",
  "description": "Shows the session cost in won above the prompt and keeps a receipt of each turn",
  "author": { "name": "AVISS" },
  "license": "MIT",
  "types": "./types/index.d.ts",
  "userConfig": {
    "krw_per_usd": {
      "type": "number",
      "title": "KRW per USD",
      "description": "Exchange rate used to show the cost in won",
      "default": 1400,
      "min": 1,
      "max": 10000
    }
  }
}

이름에서 하나 조심할 점이 있습니다. claude-나 anthropic-으로 시작하는 이름은 Anthropic 공식 플러그인처럼 보인다는 이유로 validate가 오류를 냅니다. 처음에 claude-won-meter를 떠올렸다가 문서를 보고 won-meter로 바꿨습니다.

hooks/hooks.json은 훅 모듈의 경로 하나만 적습니다. modules 키가 있어야 이 플러그인이 mod가 됩니다.

{
  "description": "won-meter hooks module",
  "modules": ["./register.tsx"]
}

types/index.d.ts는 이 mod가 $.state에 둘 값의 이름과 타입을 선언하는 계약입니다. 바깥 키는 플러그인 이름이고, validate는 코드가 읽고 쓰는 모든 상태 키가 여기에 선언되어 있는지 확인합니다.

export type Receipt = {
  turn: number
  seconds: number
  tools: number
  usd: number
  model: string
}

declare module 'claude-code' {
  interface PluginState {
    'won-meter': {
      totalUsd: number
      billedUsd: number
      contextPercent: number
      receipts: Receipt[]
    }
  }
}

2단계: 훅 모듈 쓰기

훅 모듈은 register(on, options) 하나를 내보내고, 그 안에서 on('이벤트', 매처, 훅)으로 훅을 등록합니다. 빌드 단계는 없습니다. Claude Code가 .ts, .tsx를 직접 읽고, JSX는 전역 h 함수로 컴파일됩니다.

먼저 값을 어디에 둘지 정해야 합니다. 공식 문서는 세 가지를 구분합니다(Draw in the interface).

둘 곳 언제까지 남나 won-meter에서 쓴 값
모듈 변수 모듈이 다시 로드될 때까지 (개발 중에는 저장할 때마다) 이번 턴의 도구 호출 수 tools
$.state 세션이 끝나거나 /clear, /resume, /branch 때까지 합계, 영수증 목록, 컨텍스트 비율
$.store mod가 지울 때까지 (세션을 넘어 JSON 파일로 저장) 쓰지 않음

화면이 읽는 값은 $.state에 둬야 합니다. 그리는 훅이 $.state의 값을 읽으면 그 값이 바뀔 때 Claude Code가 알아서 다시 그려 주므로 $.ui.invalidate를 부를 필요가 없고, 핫 리로드 뒤에도 값이 남습니다. 도구 호출 수처럼 턴 하나 동안만 쓰는 값은 모듈 변수로 충분합니다.

모듈의 앞부분입니다. atom으로 상태 값 하나에 이름과 기본값을 붙입니다.

import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'

import type { Receipt } from '../types'

const PANE = 'won-meter-receipt'

// 세션 동안 유지되는 값: 화면이 이 값을 읽고, 값이 바뀌면 다시 그려진다
const totalUsd = atom({ plugin: 'won-meter', key: 'totalUsd' } as const, 0)
// 영수증에 이미 올린 금액. totalUsd와의 차이는 턴 밖에서 쓴 비용이다
const billedUsd = atom({ plugin: 'won-meter', key: 'billedUsd' } as const, 0)
const receipts = atom({ plugin: 'won-meter', key: 'receipts' } as const, [])
const contextPercent = atom({ plugin: 'won-meter', key: 'contextPercent' } as const, 0)

// 1234567 -> "₩1,234,567"
function won(usd: number, rate: number): string {
  const n = Math.round(usd * rate)
  return '₩' + String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}

다음은 이벤트를 받는 훅들입니다. session.start에서 /receipt 명령을 등록하고, tool.call에서 도구 호출을 세고, turn.complete에서 영수증 한 줄을 만듭니다.

export const register: Register = (on, options) => {
  const rate = Number(options.krw_per_usd ?? 1400)
  // 모듈 변수: 핫 리로드하면 0으로 돌아가도 괜찮은 값
  let tools = 0

  on('session.start', async ($, e, next) => {
    await $.command.register({
      name: 'receipt',
      description: 'Open the won-meter receipt pane',
      immediate: true,
    })
    return next(e)
  })

  // 도구 호출 수만 센다. 호출 자체는 그대로 통과시킨다
  on('tool.call', ($, e, next) => {
    tools += 1
    return next(e)
  })

  // 메인 루프의 턴이 끝날 때마다 영수증 한 줄을 남긴다
  on('turn.complete', async ($, e, next) => {
    const done = await next(e)
    if (e.agentId !== undefined) return done

    const usage = await $.session.usage()
    const now = usage.cost?.usd ?? 0
    const before = await read($, billedUsd)
    const row: Receipt = {
      turn: (await read($, receipts)).length + 1,
      seconds: Math.round(e.durationMs / 1000),
      tools,
      usd: Math.max(0, now - before),
      model: e.usage?.model ?? '',
    }
    tools = 0
    await update($, billedUsd, () => now)
    await update($, totalUsd, () => now)
    await update($, contextPercent, () => usage.context.percent ?? 0)
    await update($, receipts, list => [...list, row].slice(-50))

    return done
  })

세 가지를 설명해 두겠습니다.

  • immediate: true: Claude가 일하는 중에도 /receipt가 바로 돌게 합니다. 이 옵션이 없으면 턴 중에 친 명령은 턴이 끝날 때까지 기다립니다.
  • e.agentId 확인: turn.complete는 서브에이전트의 턴에서도 불립니다. 서브에이전트 턴에는 agentId가 들어 있으므로 그때는 영수증을 만들지 않습니다. 이 확인이 없으면 에이전트를 많이 쓰는 세션에서 영수증 한 턴이 여러 줄로 쪼개집니다.
  • $.session.usage(): 시작 시각, 컨텍스트 사용량, 요금제 한도, 비용을 돌려줍니다. 비용은 "/cost가 합산하는 것과 같은" 값이라고 타입 선언에 적혀 있고, 인자 없이 부르면 토큰을 쓰지 않습니다. 영수증 금액은 지금 합계에서 지난번에 영수증에 올린 합계를 뺀 차이입니다.

마지막으로 화면을 그리는 두 훅입니다. ui.render를 { component: 'AbovePrompt' }로 걸러 띠를 그리고, { component: 'Pane', requestId: PANE }로 걸러 영수증 창을 그립니다. 그릴 요소(Box, Text, Button)는 전역이 아니라 $.ui.resolve(e)가 돌려주는 표에서 꺼냅니다. 터미널, 데스크톱 앱처럼 그리는 곳마다 쓸 수 있는 요소가 다르기 때문입니다.

  on('command.run', { command: 'receipt' }, async $ => {
    // 창을 열 때 한 번 더 읽어 /cost와 맞춘다
    const usd = (await $.session.usage()).cost?.usd
    if (usd !== undefined) await update($, totalUsd, () => usd)
    await $.ui.open({ id: PANE, title: 'Receipt', focus: true, closeOnEscape: true })
    return {}
  })

  // 프롬프트 위 띠: 미터기
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    const last = (await read($, receipts)).at(-1)
    if (e.props.hasSurvey || last === undefined) return next(e)

    const total = await read($, totalUsd)
    const ctx = await read($, contextPercent)
    const { Box, Text, Button } = $.ui.resolve(e)

    return (
      <Box flexDirection="row" columnGap={1}>
        <Text bold color="yellow">
          🚕 {won(total, rate)}
        </Text>
        <Text dimColor>
          (${total.toFixed(2)}) · 지난 턴 +{won(last.usd, rate)} · {last.seconds}초 · 도구 {last.tools}회 · 컨텍스트 {ctx}%
        </Text>
        <Button key="open" label="영수증" onPress={() => $.ui.open({ id: PANE, title: 'Receipt' })} />
      </Box>
    )
  })

  // 영수증 창
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
    const list = await read($, receipts)
    const total = await read($, totalUsd)
    const outside = total - (await read($, billedUsd))
    const { Box, Text } = $.ui.resolve(e)
    const room = Math.max(1, e.props.scroll.bodyRows - 4)

    return (
      <Box flexDirection="column">
        <Text bold>영수증 · 환율 {rate}원/달러</Text>
        {list.length === 0 && <Text dimColor>아직 끝난 턴이 없습니다.</Text>}
        {list.slice(-room).map(r => (
          <Text key={'row-' + r.turn}>
            #{r.turn} {won(r.usd, rate)} · {r.seconds}초 · 도구 {r.tools}회 {r.model}
          </Text>
        ))}
        {outside >= 0.005 && <Text dimColor>턴 밖 비용 {won(outside, rate)}</Text>}
        <Text bold>
          합계 {won(total, rate)} (${total.toFixed(2)}, API 정가 기준 추정)
        </Text>
      </Box>
    )
  })
}

띠 훅이 next(e)를 돌려주는 경우가 둘 있습니다. 아직 끝난 턴이 없을 때와, 설문(survey)이 띠를 쓰고 있을 때(hasSurvey)입니다. 그때는 그리지 않고 다른 mod나 Claude Code에 자리를 넘깁니다. /receipt 훅이 빈 객체 {}를 돌려주는 것은 트랜스크립트에 아무것도 찍지 않겠다는 뜻입니다.

여기까지가 처음 작성한 형태에서 6단계의 수정 두 군데(session.measure 훅과 /receipt의 재조회)를 더한 최종본입니다. session.measure 훅은 6단계에서 함께 보여 드립니다.

3단계: validate로 엔진이 읽는 모양 보기

claude plugin validate는 세션을 띄우지 않고 Claude Code가 mod를 로드할 때와 같은 정적 분석을 돌려, 이 mod가 받는 이벤트와 부르는 API를 나열합니다. mod 폴더에서 실행한 결과입니다(Claude Code 2.1.296, 2026-10-13).

❯ types ./types/index.d.ts declares state: won-meter.totalUsd, won-meter.billedUsd, won-meter.contextPercent, won-meter.receipts
❯ ./register.tsx hooks: session.start, tool.call, turn.complete, session.measure, command.run{command=receipt}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=won-meter-receipt}
❯ ./register.tsx answers its own command: command.run{command=receipt}
❯ ./register.tsx gating hook without .catch: tool.call
❯ ./register.tsx calls: $.command.register, $.session.usage, $.state.get, $.state.set, $.ui.open, $.ui.resolve
❯ ./register.tsx state writes: won-meter.billedUsd, won-meter.contextPercent, won-meter.receipts, won-meter.totalUsd
❯ ./register.tsx state reads: won-meter.billedUsd, won-meter.contextPercent, won-meter.receipts, won-meter.totalUsd

✔ Validation passed

읽을 줄은 네 가지입니다.

  • hooks:: 의도한 이벤트가 모두 있는지 봅니다. 여기서 빠진 훅은 세션에서도 불리지 않습니다.
  • calls:: 이 mod가 닿는 범위입니다. $.fs, $.process, $.http가 없으니 파일을 쓰거나 프로그램을 실행하거나 네트워크로 나가지 않는다는 것을 실행하지 않고도 알 수 있습니다. 한 국내 블로거는 "숫자 하나 띄우는 mod의 calls 줄에 네트워크 호출이 보인다면 설치를 멈추라"고 정리했습니다(몽무, 2026-10-11).
  • gating hook without .catch: tool.call: 도구 호출을 막을 수 있는 자리에 걸린 훅에 .catch 처리기가 없다는 사실 표시입니다. 이 훅이 실패하면 Claude Code는 훅을 건너뛰고 도구를 그대로 실행합니다. 가드라면 문제지만, 세기만 하는 이 mod는 실패해도 도구가 도는 쪽이 맞으므로 그대로 뒀습니다.
  • state reads/writes:: 계약 파일에 선언한 키와 맞는지 봅니다.

일부러 흔한 실수 세 가지를 넣어 보니 각각 다음 메시지로 막혔습니다.

실수 validate 메시지
const ui = $.ui처럼 $의 일부를 변수에 담음 $.ui is used as a value (a noun of $ bound, passed or read)
계약에 없는 상태 키 history를 씀 won-meter.history is not declared: the manifest's types contract must name it in interface PluginState { won-meter: { history: ... } }
이벤트 이름 오타 turn.completed "turn.completed" is not an event

첫 번째는 특히 자주 걸릴 만합니다. 일반 TypeScript에서는 아무 문제 없는 코드이지만, Claude Code는 $.namespace.method(...)처럼 호출하는 자리에 그대로 적힌 것만 읽을 수 있고, 읽지 못하는 mod는 아예 로드하지 않습니다(Create a mod). 그래서 calls: 줄을 믿을 수 있는 것이기도 합니다.

4단계: tsc와 test로 한 번 더 걸러 내기

validate는 모양을 보고 타입과 동작은 보지 않습니다. 타입은 tsc로, 동작은 claude plugin test로 확인합니다.

tsc가 찾은 11개의 오류

Claude Code는 mod를 로드할 때 .claude-plugin/types/에 이 버전의 API 선언 파일과 tsconfig.json을 깔아 줍니다. 아직 로드한 적이 없다면 내장 plugin-authoring 스킬이 같은 선언 파일을 주고, 그 파일 머리말에 맞는 tsconfig.json이 적혀 있습니다. 필자는 그 설정을 mod 폴더 밖에 두고 npx -p [email protected] tsc -p <설정 폴더>로 돌렸습니다.

validate와 test를 모두 통과한 코드에서 오류 11개가 나왔습니다. 이전 글에서 "validate와 test는 TypeScript 타입을 검사하지 않는다"는 열린 이슈(#99771)를 짚었는데, 실제로 어떤 오류가 빠져나가는지 확인한 셈입니다.

  • 본체 3개(한 가지 원인): 띠 훅에서 list[list.length - 1]로 마지막 영수증을 꺼냈는데, 권장 설정의 noUncheckedIndexedAccess 때문에 그 값이 undefined일 수 있다는 오류였습니다. .at(-1)로 꺼내고 undefined면 next(e)를 돌려주게 고쳤습니다. 실제로 영수증이 비어 있을 때 띠를 그리면 터질 수 있는 자리였습니다.
  • 테스트 8개: 스텁의 비용 목록에서 꺼낸 값이 undefined일 수 있다는 같은 종류의 오류가 2개, 턴 완료 이벤트에 usage: null을 넘겼는데 타입은 undefined만 허용한다는 오류가 3개, 띠를 그릴 때 넘기는 props에 view 필드가 빠졌다는 오류가 2개, $.command.run에 origin과 presentation이 필수라는 오류가 1개였습니다. 마지막 둘은 공식 테스트 문서의 예제도 생략하고 있어서, 테스트 실행에는 지장이 없지만 타입 검사에서는 걸립니다.

claude plugin test

claude plugin test는 세션, 로그인, 네트워크 없이 mod를 로드해 이벤트를 흘려 봅니다. 테스트 함수가 받는 $는 Claude Code 역할을 하고, on으로 등록한 훅은 Claude Code가 답할 자리를 대신하는 스텁입니다(Test a mod). 첫 테스트는 "턴이 두 번 끝나면 늘어난 비용을 원화로 띠에 그린다"입니다.

import { expect, test } from 'claude-code/testing'
import type { SessionUsage } from 'claude-code'

// AbovePrompt 띠를 그릴 때 Claude Code가 넘기는 값
const BAND = {
  plugin: 'won-meter',
  component: 'AbovePrompt',
  requestId: 'above-prompt',
  viewport: { columns: 120, rows: 40 },
  props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 116, scroll: { offset: 0, bodyRows: 10 }, view: {} },
} as const

// 세션 비용을 바꿔 가며 돌려주는 $.session.usage 대역
function usageAt(costs: number[]) {
  let i = 0
  return () => {
    const usd = costs[Math.min(i++, costs.length - 1)] ?? 0
    const value: SessionUsage = { startedAt: 0, context: { window: 200000 }, rateLimits: [], cost: { usd } }
    return { value }
  }
}

test('턴이 끝나면 늘어난 비용을 원화로 띠에 그린다', { options: { krw_per_usd: 1500 } }, async ($, on) => {
  on('session.usage', usageAt([0.5, 1.25]))
  on('tool.call', () => ({ result: 'ok' }))
  on('turn.complete', () => ({ text: '' }))

  await $.tool.call({ tool: 'Bash', command: 'ls' })
  await $.tool.call({ tool: 'Read', file_path: 'README.md' })
  await $.turn.complete({ turnId: 't1', answer: 'done', durationMs: 12_400, isAborted: false, reason: 'answer' })
  await $.turn.complete({ turnId: 't2', answer: 'done', durationMs: 3_000, isAborted: false, reason: 'answer' })

  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
  // 누적 1.25달러 x 1500원 = 1,875원, 두 번째 턴은 0.75달러 = 1,125원
  expect(await ui.find({ type: 'Text', text: '🚕 ₩1,875' })).toBeDefined()
  expect(await ui.find({ type: 'Text', text: /지난 턴 \+₩1,125 · 3초 · 도구 0회/ })).toBeDefined()
})

규칙 몇 가지를 알면 덜 헤맵니다.

  • mod가 부르는 $ 호출마다 스텁이 필요합니다. 이름에서 $.를 뗀 session.usage로 등록하고 { value }를 돌려줍니다. 이벤트 스텁(tool.call, turn.complete)은 이벤트의 결과 모양을 그대로 돌려줍니다.
  • userConfig 값은 test(이름, { options }, 본문)으로 넘깁니다. 위 테스트는 환율을 1,500원으로 바꿔서, 기본값 1,400원이 아니라 설정값을 읽는지까지 확인합니다.
  • 화면은 $.ui.mount로 그려서 find로 찾습니다. 처음에는 Text에 key="fare"를 달고 find({ key: 'fare' })로 찾으려 했는데 undefined가 나와 테스트가 실패했습니다. 필자 환경에서는 Text의 key로 찾지 못했고, find({ type: 'Text', text })처럼 종류와 글자로 찾자 통과했습니다. 공식 문서의 예제도 key는 Button에만 쓰고 Text는 글자로 찾습니다.

나머지 세 테스트는 "서브에이전트의 턴은 영수증에 넣지 않는다", "/receipt는 비용을 다시 읽고 영수증 창을 연다", "턴이 끝난 뒤 들어온 비용도 합계에 더한다"입니다. 최종 결과는 다음과 같습니다.

tests/won-meter.test.ts:
(pass) 턴이 끝나면 늘어난 비용을 원화로 띠에 그린다 [45.70ms]
(pass) 서브에이전트의 턴은 영수증에 넣지 않는다 [16.14ms]
(pass) /receipt는 비용을 다시 읽고 영수증 창을 연다 [18.57ms]
(pass) 턴이 끝난 뒤 들어온 비용도 합계에 더한다 [17.02ms]

 4 pass
 0 fail
Ran 4 tests across 1 file. [0.32s]

5단계: 실제 세션에 띄우고 핫 리로드하기

claude --plugin-dir <폴더>로 시작하면 설치하지 않고 그 세션에서만 mod를 로드하고, 폴더를 지켜보다가 저장할 때마다 다시 로드합니다.

claude --plugin-dir ./won-meter

필자는 170열 터미널에서 띄우고 "폴더의 파일 목록을 ls -R로 보여 줘", "타입 파일을 cat으로 보여 줘" 같은 짧은 요청을 두 번 보냈습니다. 첫 턴이 끝나자 띠에 🚕 ₩479 ($0.34) · 지난 턴 +₩479 · 6초 · 도구 1회가 떴고, 두 번째 턴 뒤에는 +₩67이 더해져 ₩546이 됐습니다. 터미널이 넓어 영수증 창은 트랜스크립트 오른쪽에 사이드바로 붙었습니다.

Claude Code 터미널 화면. 왼쪽 트랜스크립트 아래 프롬프트 위 띠에 노란 글씨로 택시 이모지와 ₩546, 이어서 ($0.39) 지난 턴 +₩67 5초 도구 1회 컨텍스트 6% 영수증 버튼이 있고, 오른쪽 창에는 영수증 환율 1400원/달러, #1 ₩479 6초 도구 1회 claude-opus-5-5, #2 ₩67 5초 도구 1회, 합계 ₩546 ($0.39, API 정가 기준 추정)이 적혀 있다. 트랜스크립트 중간에는 won-meter: reloaded 줄이 보인다
두 턴이 끝난 뒤의 화면. 프롬프트 위 띠가 미터기, 오른쪽이 /receipt로 연 영수증 창입니다. 중간의 won-meter: reloaded 줄은 세션 도중 코드를 고쳐 저장했을 때 찍힌 것입니다. 2026-10-13, Claude Code 2.1.296, 필자 캡처

첫 턴이 일곱 배 넘게 비쌌던 것은 프롬프트 캐시 때문으로 보입니다. /cost를 보면 이 세션의 Opus 사용량은 캐시 쓰기 40.8k 토큰, 캐시 읽기 324.4k 토큰이었습니다. /cost는 세션 합계만 보여 주므로 턴별로 나눌 수는 없지만, 첫 요청에서 시스템 프롬프트와 도구 설명을 캐시에 쓰는 비용이 첫 턴에 몰리고 그 뒤 요청은 캐시에서 읽어 훨씬 싸졌다고 보는 것이 자연스럽습니다. 미터기를 달고 나서야 "첫 질문 한 번이 그 뒤 질문 일곱 번 값"이라는 감각이 숫자로 잡혔습니다.

같은 세션의 첫 턴과 두 번째 턴 비용
같은 세션에서 비슷한 크기의 요청 두 번. 금액은 /cost와 같은 API 정가 기준 추정이며, 필자가 쓰는 Max 요금제의 실제 청구액이 아닙니다. 필자 측정

세션을 연 채로 고치기

세션을 닫지 않고 띠에 컨텍스트 비율을 더하는 코드를 저장하자 곧 트랜스크립트에 won-meter: reloaded (5 hooks: session.start, tool.call, turn.complete, command.run, ui.render) 줄이 찍히고 띠가 새 모양으로 바뀌었습니다. 이때 두 가지를 확인했습니다.

  • $.state 값은 남았습니다. 합계 ₩479와 영수증 한 줄은 그대로였습니다. 반면 새로 만든 상태 값 contextPercent는 기본값 0%로 시작했고, 다음 턴이 끝나고서야 6%가 됐습니다.
  • 모듈 변수는 0으로 돌아갑니다. 다시 로드하면 register가 새로 돌기 때문입니다. 공식 문서도 같은 이유로 화면이 읽는 값은 $.state에 두라고 권합니다(Create a mod).

일부러 깨진 코드(const ui = $.ui)를 저장해 보기도 했습니다. Claude Code는 새 버전을 거부하고 이전 버전을 계속 돌렸습니다. 띠도 멀쩡히 남아 있었고, 원래 코드로 되돌려 저장하자 바로 다시 로드됐습니다.

Claude Code 트랜스크립트에 won-meter: reload failed, the previous version stays loaded 줄과 함께 register.tsx의 compiled line 75 const ui = $.ui; 에서 $.ui is used as a value 오류가 표시되고, 그 아래 원래 코드를 저장한 뒤의 won-meter: reloaded 줄과 미터기 띠가 그대로 보이는 화면
깨진 코드를 저장했을 때. reload failed, the previous version stays loaded와 함께 validate와 같은 이유가 찍히고, 이전 버전의 미터기는 계속 그려집니다. 경로 일부는 가렸습니다. 필자 캡처

--plugin-dir로 띄운 뒤에는 mod 폴더에 tsconfig.json과 .claude-plugin/types/ 폴더가 새로 생겨 있었습니다. 앞서 말한 API 선언 파일입니다. .claude-plugin/types/는 안에 든 .gitignore로 저장소에서 빠지고, 루트의 tsconfig.json은 남습니다. 이제 에디터에서 바로 자동 완성과 타입 검사를 쓸 수 있습니다.

6단계: /cost와 금액이 다를 때 고치기

미터와 /cost를 나란히 보니 미터가 조금 적게 나왔습니다. 턴이 끝난 뒤 생긴 비용이 영수증에 잡히지 않았기 때문으로 보입니다. 첫 세션의 /cost는 Total cost: $0.4058이었는데 미터는 $0.39(₩546)였습니다. 약 1.6센트 차이입니다.

미터는 turn.complete가 불리는 순간의 합계만 읽습니다. 그러니 턴이 끝난 뒤에 생긴 요청 비용은 다음 턴까지 미터에 나타나지 않습니다. 그 세션에서는 턴이 끝난 뒤 다음 프롬프트 제안이 회색 글씨로 떴는데, 그 차이가 이런 요청의 비용인지까지는 확인하지 못했습니다. 여기서는 "턴 밖 비용"이라고만 부르겠습니다.

처음 시도한 수정은 session.measure 이벤트였습니다. 타입 선언에는 이 이벤트가 "메인 스레드의 턴이 끝난 뒤, 그리고 요금제 한도 창이 1%p 움직일 때" 불리고, 비용 합계가 늘면 changed에 cost가 들어온다고 적혀 있습니다.

  // 턴이 끝난 뒤 들어온 비용(프롬프트 제안 같은 것)까지 합계에 반영한다
  on('session.measure', async ($, e, next) => {
    const usd = e.cost?.usd
    if (e.changed.includes('cost') && usd !== undefined) {
      await update($, totalUsd, () => usd)
    }
    return next(e)
  })

테스트에서는 이 훅이 의도대로 합계를 올렸습니다. 하지만 새 세션에서 한 턴을 돌려 보니 미터는 ₩448($0.32)에 멈춰 있었고 /cost는 $0.3379였습니다. 15초 넘게 기다려도 미터가 바뀌지 않았으니, 이 세션에서는 턴 밖 비용이 session.measure로 들어오지 않은 셈입니다. 테스트는 "이벤트가 오면 이렇게 처리한다"를 확인할 뿐, 엔진이 언제 그 이벤트를 보내는지는 확인하지 않는다는 점을 여기서 실감했습니다.

그래서 두 번째로 /receipt를 열 때 $.session.usage()를 한 번 더 읽게 고쳤습니다(2단계 코드의 command.run 훅). 세션을 연 채로 저장하자 핫 리로드됐고, /receipt를 다시 열자 영수증 창에 "턴 밖 비용 ₩25"가 따로 찍히고 합계가 ₩473이 됐습니다. $0.3379에 1,400원을 곱하면 473원이므로 /cost와 맞습니다.

Claude Code 2.1.296 시작 화면 오른쪽의 영수증 창. 영수증 환율 1400원/달러, #1 ₩448 9초 도구 1회 claude-opus-5-5, 흐린 글씨로 턴 밖 비용 ₩25, 합계 ₩473 ($0.34, API 정가 기준 추정)이 적혀 있다
수정 뒤의 영수증 창. 턴 영수증 ₩448에 턴 밖 비용 ₩25를 따로 적어 합계 ₩473이 /cost의 $0.3379와 맞습니다. 필자 캡처

이 수정 직후 claude plugin test를 돌리자 /receipt 테스트가 깨졌습니다. 메시지는 이랬습니다.

HooksError: no implementation for command.run
the engine reported:
  won-meter's command.run hook was skipped: won-meter: no implementation for session.usage

명령 훅이 새로 $.session.usage()를 부르는데 테스트에 그 스텁이 없어 훅이 건너뛰어진 것입니다. 실제 세션이라면 훅이 실패할 때 명령이 아무 답도 하지 않았을 상황이라, 테스트가 회귀를 잡아 준 셈입니다. 스텁을 더하고 창에 "합계 ₩700"이 그려지는지 확인하는 줄을 넣어 다시 통과시켰습니다.

막혔을 때 볼 곳

세션에서 mod가 아무것도 안 할 때는 다음 순서로 확인하면 됩니다.

  1. claude plugin validate <폴더>로 hooks: 줄을 봅니다. 이벤트 이름 오타, 매니페스트 오류, 읽을 수 없는 코드가 여기서 나옵니다.
  2. --plugin-dir로 띄운 세션이라면 트랜스크립트의 흐린 줄을 봅니다. hook skipped, reload failed, ui.render (Pane) refused 같은 줄이 mod 이름과 함께 찍힙니다.
  3. 그래도 없으면 디버그 로그를 남깁니다. 다른 터미널에서 mod 이름으로 걸러 보면 로드, 실패, 거부된 결과가 한 줄씩 나옵니다(Troubleshoot a mod).

디버그 로그를 남기는 명령은 다음과 같습니다.

claude --debug-file ./mod-debug.log --plugin-dir ./won-meter
tail -f ./mod-debug.log | grep won-meter

2.1.296에서는 --debug 출력의 훅 정보도 보강됐습니다(Claude Code changelog).

7단계: 공유하고 설치하기

mod 폴더에 .claude-plugin/marketplace.json 하나를 더하면 그 저장소가 마켓플레이스가 되고, 다른 사람은 한 줄로 설치할 수 있습니다.

{
  "name": "won-meter",
  "description": "A Claude Code mod that shows the session cost in won",
  "owner": { "name": "AVISS" },
  "plugins": [{ "name": "won-meter", "source": "./" }]
}

이 폴더를 GitHub 저장소로 올리면 받는 사람은 터미널 세션의 프롬프트에서 다음 한 줄을 칩니다. 아래 줄은 필자의 validate가 README 조언으로 출력한 형태 그대로입니다. 2.1.292 변경 기록은 claude plugin install의 --marketplace 옵션이 "필요하면 마켓플레이스를 추가한 뒤 그 마켓플레이스에서 플러그인을 설치한다"고 설명합니다.

/plugin install won-meter --marketplace <owner>/<repo>

2.1.295부터 validate는 README에 이 설치 줄이 없으면 붙여 넣을 줄을 알려 줍니다. 필자의 README에는 <owner>/<repo> 자리표시자만 있어서 같은 조언이 떴고, 조언은 검증 결과(✔ Validation passed)를 바꾸지 않습니다. 이 글의 mod는 실제로 저장소에 올리지 않았으므로 설치 과정은 공식 문서의 설명까지만 확인했습니다.

공유할 때 기억할 점이 둘 있습니다.

  • 개발은 설치본이 아니라 --plugin-dir로 합니다. 설치한 플러그인은 버전별로 캐시된 복사본이 돌기 때문에, 원본을 고쳐도 version을 올려 다시 설치하기 전에는 반영되지 않습니다.
  • README에 확인한 Claude Code 버전을 적습니다. API 선언 파일 머리말에도 "EARLY ACCESS: this surface may change between releases without notice"라고 적혀 있듯, 이벤트와 메서드가 릴리스마다 바뀔 수 있습니다. 직접 mod를 만들어 본 한 국내 개발자도 업데이트 때 mod가 깨질 수 있다는 커뮤니티의 우려를 전했습니다(멍개, 2026-10-11).

Claude에게 시키면 무엇이 다른가요?

같은 mod를 말로 시킬 수도 있습니다. 그 흐름(내장 plugin-authoring 스킬, ~/.claude/dev-mods/ 폴더, 핫 리로드 승인)은 이전 글에 정리했습니다.

필자는 이번에 스킬이 주는 API 선언 파일과 예제만 참고하고, mod는 블로그 저장소 안의 폴더에 직접 썼습니다. 이유는 두 가지입니다. 하나는 각 단계의 출력을 글로 남기기 위해서였고, 다른 하나는 필자의 ~/.claude 설정에 실험용 mod가 남지 않게 하기 위해서였습니다. 시켜서 만들 때도 앞의 검사 순서는 그대로 쓸 수 있습니다. 프롬프트에 "validate, tsc, claude plugin test까지 통과시키고, 테스트는 서브에이전트 턴을 제외하는지 확인하는 것을 포함해 줘"처럼 확인 조건을 함께 적으면 결과를 검증하기 쉽습니다.

이 글의 한계

  • macOS 터미널에서만 확인했습니다. 데스크톱 앱의 Code 탭과 Windows, VS Code 확장(훅은 돌지만 띠와 창은 그려지지 않음)에서는 시험하지 않았습니다.
  • 금액은 추정치입니다. $.session.usage()의 비용은 /cost와 같은 API 정가 기준 계산이고, Pro나 Max 요금제의 실제 청구액이 아닙니다. 공식 샘플의 컨텍스트 게이지를 두고 "계정의 남은 한도나 결제 금액으로 해석하면 안 된다"고 짚은 국내 글(juj9990)과 같은 주의가 이 미터기에도 필요합니다. 환율 1,400원도 실제 환율이 아니라 바꿔 쓰는 설정값입니다.
  • 턴 밖 비용의 정체는 확인하지 못했습니다. /receipt를 열 때 다시 읽는 방식으로 합계만 맞췄습니다. 띠는 다음 턴이 끝나거나 영수증을 열 때까지 이전 합계를 보여 줍니다.
  • 세션 두 개, 턴 세 번으로 확인했습니다. 서브에이전트를 많이 쓰는 긴 세션이나 /clear 뒤의 동작($.state가 초기화되고 /cost도 0부터 다시 셈)은 테스트 코드로만 일부 확인했습니다.

자주 묻는 질문

tsc는 꼭 돌려야 하나요?

필수는 아니지만 권합니다. validate는 엔진이 읽는 모양을, claude plugin test는 동작을 보고, 둘 다 TypeScript 타입은 검사하지 않습니다. 이 글의 mod에서는 두 검사를 통과한 코드에서 tsc가 오류 11개를 찾았고, 그중 셋(한 가지 원인)은 영수증이 비어 있을 때 띠를 그리다 터질 수 있는 자리였습니다. --plugin-dir로 한 번 띄우면 mod 폴더에 tsconfig.json이 생기므로 그다음부터는 npx -p typescript tsc -p <mod 폴더>처럼 돌릴 수 있습니다. 필자도 같은 방식으로 오류 0개를 확인했습니다.

미터기를 /clear 뒤에도 이어서 쓰려면 어떻게 하나요?

/clear, /resume, /branch는 $.state를 기본값으로 되돌리고, 이때 session.start는 다시 불리지 않습니다. 세션을 넘어 남길 값은 $.store에 함께 저장하고, classic.SessionStart 훅에서 source가 clear, resume, fork일 때 다시 읽어 $.state에 넣으면 됩니다(Draw in the interface). 다만 /cost의 합계도 /clear에서 0으로 돌아가므로, 이 mod처럼 세션 비용을 보여 주는 용도라면 초기화되는 편이 맞습니다.

띠에 버튼을 넣으면 키보드로 누를 수 있나요?

띠나 창이 키보드 초점을 가졌을 때만 누를 수 있습니다. Ctrl+X 다음 Tab을 누르거나 마우스로 클릭하면 초점이 옮겨 가고, Esc로 프롬프트에 돌아옵니다. 한 글자 단축키를 주려면 Button에 hotkey를 답니다. 이 글의 /receipt 명령처럼 focus: true로 연 창은 프롬프트가 비어 있으면 열리자마자 초점을 가집니다.

이 mod가 위험하지는 않나요?

validate의 calls: 줄에 $.command.register, $.session.usage, $.state, $.ui만 있어 파일 쓰기, 프로그램 실행, 네트워크 요청을 하지 않습니다. 그래도 mod는 샌드박스 없이 사용자 권한으로 돌기 때문에, 남이 만든 mod를 설치할 때는 같은 방법으로 calls: 줄부터 확인하세요. 공개 mod들이 어디까지 닿는지는 이전 글의 보안 절에 정리해 두었습니다.

마치며

won-meter는 파일 네 개, 훅 모듈 128줄, 테스트 4개짜리 작은 mod입니다. 그래도 만드는 동안 mod 개발에 필요한 고리를 한 바퀴 다 돌았습니다. 상태를 어디에 둘지 정하고, validate로 엔진이 읽는 모양을 보고, tsc와 claude plugin test로 타입과 동작을 확인하고, --plugin-dir로 띄워 세션을 연 채 고치고, 실제 숫자와 맞지 않는 부분을 찾아 고친 뒤 테스트로 회귀를 막는 과정입니다.

처음 만든다면 이 글의 미터기처럼 "늘 궁금하던 숫자 하나를 프롬프트 위에 띄우는 mod"부터 시작해 보세요. 띠 하나, 훅 두세 개면 충분하고, 그다음에 창과 명령을 붙이면 됩니다. mod의 개념과 공식 샘플, 보안 점검은 Claude Code Mods 구조와 10가지 활용에, Claude Code를 처음 쓴다면 기본기는 Claude Code 팁 11가지에 정리해 두었습니다.

출처

  1. 코드팩토리, Claude Code Mods 설명, 10가지 사용 사례, 2026-10-06: youtube.com/watch?v=z-GwTV_qID8
  2. devbrothers-mods: github.com/devbrother2024/devbrothers-mods
  3. Customize your status line: code.claude.com/docs/en/statusline
  4. Anthropic, Draw in the interface with a mod: code.claude.com/docs/en/plugins/mods/interface
  5. Plugin manifest reference: code.claude.com/docs/en/plugins/manifest-reference
  6. Draw in the interface: code.claude.com/docs/en/plugins/mods/interface
  7. 몽무, 2026-10-11: blog.naver.com/mummumni/224437890547
  8. Create a mod: code.claude.com/docs/en/plugins/mods/create
  9. #99771: github.com/anthropics/claude-code/issues/99771
  10. Test a mod: code.claude.com/docs/en/plugins/mods/test
  11. Create a mod: code.claude.com/docs/en/plugins/mods/create
  12. Troubleshoot a mod: code.claude.com/docs/en/plugins/mods/troubleshoot
  13. Claude Code changelog: code.claude.com/docs/en/changelog
  14. 멍개, 2026-10-11: blog.naver.com/pjt3591oo/224437595468
  15. juj9990: blog.naver.com/juj9990/224439201496
  16. Draw in the interface: code.claude.com/docs/en/plugins/mods/interface