Claude Code のコスト・トークンを可視化する CLI を作って npm publish した話
はじめに
Claude Code を使い始めて 3 ヶ月、コストとトークン消費が気になってきました。Anthropic Console で月の合計は見えますが、「どのプロジェクトに何時間費やしたか」「どのモデルがコストを支配しているか」のような粒度では見えません。
ただ、ローカルには答えがあります。Claude Code はセッションログを ~/.claude/projects/<project>/<session>.jsonl に JSONL で書き出していて、トークン数・モデル・ツール呼び出し・コスト計算に必要な情報がほぼ全部入っています。これをローカルだけで集計する CLI を作って npm に公開したのが、本記事で扱う @kojihq/lens(コマンド名 koji-lens)です。
実装の見どころは 3 つ:
- CLI に Web ダッシュボードを standalone bundle で同梱(Next.js 16 + npm 配布)
- better-sqlite3 + drizzle-orm のキャッシュ層で初回 4.19 秒 → 2 回目 0.63 秒(6.7 倍)
-
zod の
passthrough()+optional()で、フィールドが時期で変動する JSONL を前方互換のままパース
解決する課題
~/.claude/projects/ の下には、プロジェクトごとにセッション 1 本 = 1 ファイルの JSONL ログが積まれます。1 行 1 イベント(ユーザー発話 / アシスタント返答 / ツール呼び出し / 結果)の追記型で、各イベントに usage(input/output/cache_read/cache_create のトークン数)と model が付いています。「先週いくら使ったか」「どのプロジェクトに何時間費やしたか」を答える材料は揃っているのに、JSONL を直接眺めても集計できず、Anthropic Console の使用量サマリは粒度が粗くて横断分析できない、というギャップがありました。
@kojihq/lens は 「ローカルにあるデータをローカルだけで集計する」 に絞った CLI です。クラウド送信もアカウントも要りません。1 コマンドで入って、1 コマンドで動きます。
koji-lens でできること
API キー直接利用と、サブスク(Claude Pro / Max)経由利用のどちらでも使えます。コスト表示は API レート換算値で、サブスク利用者には注記付きで「使用量の目安」として表示します。
サブコマンドは 5 つ: summary(集計ヘッダ + TOTAL + セッション詳細、--summary-only で軽量モード)、sessions(1 行 1 セッションの一覧)、session <id>(個別詳細)、serve(Web ダッシュボード起動)、config。
summary は TOTAL を冒頭に出す設計です。画面下までスクロールしないと合計が見えない第一印象問題を避けるためで、出力はこうなります。
$ koji-lens summary --since 24h
koji-lens — analyzed 15 session(s)
period: 2026-04-27 15:00 → 2026-04-28 15:00 local (last 24h)
============================================================
TOTAL
sessions: 15
duration (sum): 16h 24m 10s
turns: assistant=1622, user=833, sidechain=100
tokens: input=133,976, output=2,855,592, cache_read=353,399,039, cache_create=7,356,247
cost: $874.1162 (¥135,488)
cost by model: claude-opus-4-7=$865.4523, claude-sonnet-4-6=$8.6639
tools: Bash×238, Edit×194, Read×179, TaskUpdate×72, Write×55, TaskCreate×37, Grep×30, Glob×18, Agent×8, ToolSearch×3
models: claude-opus-4-7×1562, claude-sonnet-4-6×60
note: Cost is API-rate equivalent (token × Anthropic API price).
Actual billing depends on your plan — Claude Pro / Max
subscribers pay a flat fee regardless of this number.
============================================================
Session 055a662d-f09c-4541-b54c-7ad4a9130f3d
file: ~/.claude/projects/<project-a>/055a662d-f09c-4541-b54c-7ad4a9130f3d.jsonl
started: 2026-04-27T23:54:36.332Z (2026-04-28 08:54 local)
ended: 2026-04-28T00:00:27.140Z (2026-04-28 09:00 local)
duration: 5m 51s
turns: assistant=19, user=10, sidechain=0
tokens: input=34,381, output=18,896, cache_read=823,436, cache_create=178,695
cost: $6.5186 (¥1,010)
models: claude-opus-4-7×19
tools: Bash×6, Read×4
(以下、14 セッション省略)
注目してほしいのは cost by model: 行。turn 数だけ見ていた頃は気づきませんでしたが、コストで見ると Opus 99% / Sonnet 1% の偏りでした。「軽い処理まで Opus に投げているのでは」という疑いにはじめて数字で裏が取れ、Sonnet 化でいくら浮くかの試算根拠にもなります。started: / ended: は UTC とローカルタイムを併記しています。
cron 用途には --summary-only で TOTAL のみの軽量モードがあります。
--format json --summary-only で total だけの JSON が取れるので、シェルで「閾値を越えたら通知」のような使い方もできます。
セッション横断の俯瞰には sessions を使います。
$ koji-lens sessions --since 24h --limit 5
055a662d-f09c-4541-b54c-7ad4a9130f3d 2026-04-28T00:03:34.073Z duration=8m 58s cost=$9.6253 turns=30a/13u tools=2
44c29745-eb72-48a1-adac-621e742458b7 2026-04-27T08:42:30.453Z duration=1h 7m 33s cost=$32.7546 turns=118a/54u tools=6
28cf16fa-26f8-4182-966a-7006384c3ef5 2026-04-27T07:54:28.868Z duration=7h 38m 22s cost=$420.6613 turns=688a/333u tools=9
agent-a94b75b8c4892e9d0 2026-04-27T07:41:28.859Z duration=1m 9s cost=$0.2880 turns=9a/7u tools=2 ↳ subagent of 28cf16fa
22a919c7-a7fc-4787-9679-11e9e60d5f75 2026-04-27T07:34:42.464Z duration=12m 4s cost=$20.7381 turns=63a/27u tools=5
agent-* で始まる行は subagent(Claude Code が呼び出す別エージェントセッション)です。Sonnet 中心の短時間・低コスト処理が多く、Opus のロング作業($420 / $32)と性質が違います。末尾の ↳ subagent of <parent-id-8> で親 ID 先頭 8 文字を併記しているので、ファイルパスを読まなくても親子関係が見えます。
気になるセッションは ID で session <id> を叩くと詳細が出ます(file: のパスが <main-session-id>/subagents/<agent-id>.jsonl の親子構造になっている点に注目してください。Claude Code は subagent ログをメインセッションのフォルダ配下に格納する設計で、@kojihq/lens はその構造もそのまま拾います)。
ブラウザで見たいときは koji-lens serve で localhost:3000 が立ち上がり、Recharts による日別コスト棒グラフ・ツール円グラフ・トークン積み上げ棒・セッション一覧が並びます。CLI と同じデータを並べ直しただけです。
節約効果の before/after 可視化、複数マシン間のクラウド同期、月次レポートメール、コスト予算アラートなどは Pro 機能として開発中です(2026 年 5 月末頃の販売開始予定)。リリース通知の Wait list を LP(lens.kojihq.com) で受け付けています。
技術スタックと選定理由
npm 標準の組合せだけで作っています。
-
pnpm モノレポ:
@kojihq/core(パーサ・集計・キャッシュ)+@kojihq/lens(CLI 本体 + Web standalone 同梱)+apps/web(Next.js ダッシュボード)の 3 パッケージ - TypeScript + zod: JSONL の型安全パース(後述 §4.4.3)
- commander: CLI フレームワーク
- better-sqlite3 + drizzle-orm: キャッシュ層(後述 §4.4.2)
- Next.js 16 + Tailwind v4 + Recharts: Web ダッシュボード。Server Component で SQLite を直読みする最小構成
実装の見どころ 3 つ
CLI に Web ダッシュボードを standalone で同梱する
pnpm add -g @kojihq/lens だけで CLI と Web ダッシュボードの両方が入り、koji-lens serve で localhost:3000 が立ち上がります。これを 1 つの npm パッケージに同梱するのが、地味に厄介でした。
仕組みは Next.js 16 の output: "standalone" です。.next/standalone/ 配下に必要な依存だけを含んだ Node.js サーバが吐かれるので、CLI からそれを起動します。理想形は単純ですが、3 つ落とし穴を踏みました。
// apps/web/next.config.ts(全文 13 行)
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(currentDir, "../../"),
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;
-
outputFileTracingRoot: pnpm モノレポでは依存トレーサーにモノレポルートを教えないとnode_modulesを見失う -
serverExternalPackages: ["better-sqlite3"]: 入れないと Turbopack が native module を hash 化してCannot find module 'better-sqlite3-<hash>'で起動失敗 -
server.js の絶対パス焼き込み: Next.js は
outputFileTracingRootのビルド時絶対パスをserver.jsのリテラルに焼き込む。npm 配布だとプライバシー漏洩 + 別環境で機能しない
この 3 点を解消するまでに beta.0 → beta.2 の 3 回の hotfix を出しました。3 つ目は、bundle スクリプト側で正規表現サニタイズして対処しています。
// apps/cli/scripts/bundle-web-standalone.mjs(核となる 12 行抜粋)
const serverJsPath = path.join(target, "apps/web/server.js");
if (existsSync(serverJsPath)) {
const original = readFileSync(serverJsPath, "utf8");
const sanitized = original
.replace(/"outputFileTracingRoot":"[^"]*"/g, '"outputFileTracingRoot":"./"')
.replace(/"turbopack":\{"root":"[^"]*"\}/g, '"turbopack":{"root":"./"}');
if (sanitized !== original) {
writeFileSync(serverJsPath, sanitized);
console.log("[bundle-web-standalone] Sanitized build-machine absolute paths in server.js");
}
}
flattenPnpmModules(path.join(target, "node_modules"));
最後の flattenPnpmModules は pnpm の symlink 構造を npm 互換に展開し直す処理です。これを入れる前は pnpm add @kojihq/lens 後に Cannot find module 'next' で起動できませんでした。原因は pnpm workspace の .pnpm/ 配下の symlink が registry 経由の install で破綻していたこと。flat 化することで解消し、@kojihq/lens@0.1.0-beta.2 でようやく動くようになりました。
落とし穴を踏み終わって残るのは、pnpm add -g @kojihq/lens 1 コマンドで CLI と Web ダッシュボードが同時に入る配布形態です。同じデータを同じバイナリで CLI / ブラウザ切り替えできる構成になります。
SQLite キャッシュ層で 6.7 倍高速化(実測)
JSONL は追記型なので毎回フルスキャンでも正しく集計できますが、私のログ(約 130 セッション)を --since 30d で集計すると 約 2.6 秒 かかりました。koji-lens summary を日に何度も叩く前提だと少し遅いので、better-sqlite3 + drizzle-orm でキャッシュ層を入れました。1 ファイル = 1 セッション = 1 行の素直な schema です。
// packages/core/src/db/schema.ts(全文 44 行)
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const sessions = sqliteTable("sessions", {
sessionId: text("session_id").primaryKey(),
filePath: text("file_path").notNull(),
mtimeMs: integer("mtime_ms").notNull(),
cachedAt: integer("cached_at").notNull(),
startedAt: text("started_at"),
endedAt: text("ended_at"),
durationMs: integer("duration_ms").notNull().default(0),
assistantTurns: integer("assistant_turns").notNull().default(0),
userTurns: integer("user_turns").notNull().default(0),
sidechainCount: integer("sidechain_count").notNull().default(0),
inputTokens: integer("input_tokens").notNull().default(0),
outputTokens: integer("output_tokens").notNull().default(0),
cacheReadTokens: integer("cache_read_tokens").notNull().default(0),
cacheCreateTokens: integer("cache_create_tokens").notNull().default(0),
costUsd: real("cost_usd").notNull().default(0),
costsByModelJson: text("costs_by_model_json").notNull().default("{}"),
modelsJson: text("models_json").notNull().default("{}"),
toolsJson: text("tools_json").notNull().default("{}"),
});
肝は mtimeMs(ファイルの最終更新ミリ秒)をキーに 更新差分のみ再パースすることです。
JSONL は追記型で、1 セッションにつき 1 ファイル。セッションが完了すれば以降は書き換えられません。「mtime が変わっていなければ内容も変わっていない」が保証されるのはこの性質によるもので、ファイル hash を計算する必要はありません。判定は 1 SELECT で済みます。
// packages/core/src/db/cache.ts:60-71
export function isCacheFresh(
db: BetterSQLite3Database,
sessionId: string,
currentMtimeMs: number,
): boolean {
const row = db
.select({ mtimeMs: sessions.mtimeMs })
.from(sessions)
.where(eq(sessions.sessionId, sessionId))
.get();
return row ? row.mtimeMs >= currentMtimeMs : false;
}
cache miss なら JSONL を全行パース → upsert で上書き、cache hit なら SQLite から 1 行 SELECT して終了。SSD の stat 呼び出しは数十マイクロ秒なので 130 セッションでも気になりません。
実測値(Node 22.16.0、Windows 11、SSD、--since 30d、約 130 セッション):
| シナリオ | 実測時間 |
|---|---|
| Cache miss(cold start、全 JSONL パース + cache 構築) | 4.19 秒 |
| Cache hit(cache から再集計のみ) | 0.63 秒 |
--no-cache(毎回フルスキャン、cache に書かない) |
2.63 秒 |
Cold start vs hot で 6.7 倍、no-cache vs hot で 4.2 倍。実装の単純さに対して効果は十分です(ファイル mtime と SQLite だけで足りる)。集計対象の規模で数字は変わるので、自分のログで何秒になるかは --no-cache で対比してみてください。
なお、cost_by_model 列追加で cache schema の migration が発生したので、PRAGMA user_version で 1 → 2 を見て古い cache を DROP → 再構築するパスを入れています。ローカル cache なので「初回だけ cold start に戻る」だけで済みます。
JSONL ログを zod の passthrough() + optional() でパースする
Claude Code の JSONL は、3〜4 種のメッセージタイプ(assistant / user / tool_use / tool_result 系)が混在し、トークン種別の cache 系(cache_read_input_tokens / cache_creation_input_tokens)が後から追加されている痕跡もあるなど、フィールド構成が時期で変動します。
discriminated union で書くと「未知フィールドで失敗 / 古い形に非互換」になりがちなので、ここは passthrough() + optional() の組合せで書いています。
// packages/core/src/schema.ts(全文 58 行、抜粋)
import { z } from "zod";
export const UsageSchema = z
.object({
input_tokens: z.number().optional(),
output_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional(),
cache_creation_input_tokens: z.number().optional(),
})
.passthrough();
export const MessageSchema = z
.object({
model: z.string().optional(),
usage: UsageSchema.optional(),
content: z.array(ContentItemSchema).optional(),
})
.passthrough();
export const ClaudeCodeRecordSchema = z
.object({
type: z.string(),
sessionId: z.string().optional(),
uuid: z.string().optional(),
timestamp: z.string().optional(),
isSidechain: z.boolean().optional(),
message: MessageSchema.optional(),
})
.passthrough();
export function parseRecord(line: string): ClaudeCodeRecord | null {
const trimmed = line.trim();
if (!trimmed) return null;
let raw: unknown;
try {
raw = JSON.parse(trimmed);
} catch {
return null;
}
const parsed = ClaudeCodeRecordSchema.safeParse(raw);
if (!parsed.success) return null;
return parsed.data;
}
設計のキモは、passthrough() で未知フィールドを保持し(前方互換)、optional() で type 以外を任意化し(後方互換)、safeParse() で例外を投げず null を返す、の 3 点です。集計側は if (!record) continue; で 1 行スキップして次に進むだけなので、書き込み途中で切れたファイル末尾や不正レコードが 1 行混ざっていても 集計全体が止まりません。運用の堅牢性に効きます。
設計判断 — なぜローカル完結か
クラウド送信なし・アカウント不要の設計を選んでいる理由は 2 つです。
1 つ目は、社外送信 NG ポリシーの企業エンジニアでも入れられるようにしたかったから。Claude Code のセッションログには社内コードや障害再現手順といった「持ち出してはいけない情報」が混ざります。集計のためだけにクラウドへ送る選択肢は多くの会社で通りません。ローカルで完結すればその壁を越えなくて済みます。
2 つ目は、@kojihq/lens の Free Tier は永続的にローカル完結のまま運用する設計だから。Pro 機能としてクラウド同期や月次レポート配信は予定していますが、それはオプトインで使う人だけです。Free で入れた CLI が、ある日「クラウドに送らないと動かない」ようになる、ということは仕様として起きません。
インストール・使い方
pnpm add -g @kojihq/lens
koji-lens summary
koji-lens serve
- 動作要件: Node.js 22+
- Claude Code の JSONL ログがある環境(
~/.claude/projects/)
pnpm の代わりに npm i -g でも bun add -g でも入ります。最初に koji-lens summary を叩いて、TOTAL ブロックが出力されれば動いています。
今後
@kojihq/lens@0.1.0-beta.2 時点では、β 期間として 5 月いっぱい安定化に使います。フィードバックは GitHub Issues か Bluesky @kojihq.com でお気軽にどうぞ。
1.0 へ向けては、SQL ライクなクエリ機能(koji-lens query "SELECT ...")と、エクスポート機能(CSV / Parquet)の追加を予定しています。
Pro 機能(節約効果の before/after 可視化、複数マシン間のクラウド同期、月次レポートメール、コスト予算アラート、チーム共有)は開発中で、差別化機能が揃った段階で Pro 販売を開始予定です(2026 年 5 月末頃)。
Pro 機能のローンチ通知を受け取りたい方は LP(lens.kojihq.com) の Wait list へどうぞ。フィードバックは GitHub Issues か Bluesky
@kojihq.comまでお気軽に。
リンク
- GitHub: https://github.com/etoryoki/koji-lens
- npm (lens): https://www.npmjs.com/package/@kojihq/lens
- npm (core): https://www.npmjs.com/package/@kojihq/core
- LP: https://lens.kojihq.com
- Bluesky:
@kojihq.com
Discussion