macOS のメニューバーに Codex と Claude Code の利用率を常駐表示するアプリ「AI Usage Menubar」を試したところ、clone 直後のビルドが失敗しました。原因を追ったついでに、Codex 版に足りていなかった週間利用枠の表示も実装したので、その手順をまとめます。

元記事

このアプリは szk1akhr さんの以下の記事で紹介されているものです。

Objective-C + AppKit で書かれた外部ライブラリ非依存のネイティブアプリです。特筆すべきは利用量の取得に追加の API リクエストを一切行わない設計で、Codex 版はローカルのセッションログ (JSONL) を、Claude Code 版は statusLine のキャッシュを読みます。API キーの管理が不要で、常駐ツールとして理にかなった作りだと思います。

この記事は、そのアプリに対する以下の2点の作業記録です。

  1. clone 直後に make build が失敗するバグの修正
  2. Codex 版に週間利用枠 (7日枠) の表示を追加

検証環境

項目
macOS 26.5.1 (Apple Silicon / M2)
Xcode Command Line Tools インストール済み
Codex CLI Homebrew 経由 (team プラン)
Claude Code 2.1.174

第1部: ビルドエラーの修正

症状

README の手順どおりに clone してビルドすると、こうなります。

$ git clone https://github.com/akh1r0ck/ai-usage-menubar.git
$ cd ai-usage-menubar
$ make build
./scripts/build-app.sh
/var/folders/.../tmp.stSQWz2ntO/ChatGPT Usage.app: replacing existing signature
mv: rename /var/folders/.../tmp.stSQWz2ntO/ChatGPT Usage.app to
    /Users/xxx/src/ai-usage-menubar/dist/ChatGPT Usage.app: No such file or directory
make: *** [build-codex] Error 1

replacing existing signature まで出ているので、コンパイルと署名は成功しています。落ちているのは最後の mv だけです。

原因

scripts/build-app.sh を読むと構造がはっきりします。

#!/bin/sh
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
APP_DIR="$PROJECT_DIR/dist/ChatGPT Usage.app"
STAGING_DIR=$(mktemp -d)
trap 'rm -rf "$STAGING_DIR"' EXIT
STAGING_APP="$STAGING_DIR/ChatGPT Usage.app"
CONTENTS="$STAGING_APP/Contents"

cd "$PROJECT_DIR"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"   # ← これは一時ディレクトリ側
clang -fobjc-arc ... -o "$CONTENTS/MacOS/ChatGPTUsage"
cp "Resources/Info.plist" "$CONTENTS/Info.plist"
printf 'APPL????' > "$CONTENTS/PkgInfo"
xattr -cr "$STAGING_APP"
codesign --force --sign - "$STAGING_APP"
rm -rf "$APP_DIR"
mv "$STAGING_APP" "$APP_DIR"                        # ← ここで落ちる

ビルドは mktemp -d で作った一時ディレクトリで行い、完成品を dist/mv する流れです。ところが、

  • mkdir -p "$CONTENTS/MacOS" が作るのは一時ディレクトリ側であって dist/ ではない
  • rm -rf "$APP_DIR" は対象が存在しなくてもエラーにならず黙って通る
  • mv は移動先の親ディレクトリを自動生成しない

という三段構えで、dist/ が無いまま mv に到達します。

再現条件は「dist/ が一度も作られていない状態」、つまり clone 直後か make clean 直後です。作者の手元では過去のビルドで dist/ が残っていたため、気づかれなかったのだと思われます。

その場の回避策

dist/ を手で作れば通ります。

$ mkdir -p dist
$ make build

恒久的な修正

mv の前に1行足すだけです。scripts/build-app.shscripts/build-claude-app.sh の両方に同じ修正を入れます。

  codesign --force --sign - "$STAGING_APP"
+ mkdir -p "$PROJECT_DIR/dist"
  rm -rf "$APP_DIR"
  mv "$STAGING_APP" "$APP_DIR"

これで誰が clone しても初回ビルドが通るようになります。


第2部: Codex 版に週間利用枠を追加する

きっかけ

Codex CLI の画面には「5時間」と「1週間」の2つの枠が表示されます。

残り使用量
  5時間   76%  14:49
  1週間   87%  9月7日

ところが AI Usage Menubar の Codex 版は5時間枠しか表示しません。Claude Code 版は「セッション」と「週間」の2枠を表示しているのに、Codex 版だけ片方だけなのは惜しい。

Step 1: データが手元に来ているか確認する

改造の可否は、ローカルのデータに週間枠が含まれているかの一点で決まります。含まれていなければ、このアプリの「追加 API 通信をしない」という設計上、どうやっても表示できません。

Codex は ~/.codex/sessions/**/*.jsonl にセッションを記録します。最新ファイルの最後の token_count イベントから rate_limits を抜き出してみます。

$ f=$(ls -t ~/.codex/sessions/**/*.jsonl | head -1)
$ grep '"token_count"' "$f" | tail -1 | python3 -c \
  'import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get("payload",{}).get("rate_limits"), indent=2))'

結果:

{
  "limit_id": "codex",
  "limit_name": null,
  "primary": {
    "used_percent": 30.0,
    "window_minutes": 300,
    "resets_at": 1788328159
  },
  "secondary": {
    "used_percent": 14.0,
    "window_minutes": 10080,
    "resets_at": 1788761372
  },
  "credits": { "has_credits": false, "unlimited": false, "balance": null },
  "plan_type": "team"
}

secondary があり、window_minutes10080 = ちょうど7日。週間枠のデータは最初から届いていました。

Step 2: なぜ表示されていなかったのか

Sources/UsageProvider.m のパース部分を見ると、原因は1行に集約されていました。

NSDictionary *limits = payload[@"rate_limits"] ?: @{};
NSDictionary *window = limits[@"primary"] ?: limits[@"secondary"];   // ← ここ
if (![window isKindOfClass:NSDictionary.class]) continue;
snapshot.available = YES;
snapshot.primaryPercent  = [window[@"used_percent"] doubleValue];
snapshot.primaryReset    = [window[@"resets_at"] doubleValue];
snapshot.windowMinutes   = [window[@"window_minutes"] integerValue];

?:フォールバックであって、両方を読む処理ではありません。primary があれば secondary は見ずに捨てられます。

一方、Sources/UsageProvider.h を見ると:

@property BOOL hasPrimary;
@property double secondaryPercent;
@property BOOL hasSecondary;
@property NSTimeInterval secondaryReset;
@property NSInteger windowMinutes;

受け皿はすでに用意されていました。 Claude 版 (claude_main.m) がこれを使って2枠を描いています。つまり Codex 側のパーサーが埋めていないだけ、という状態でした。

Step 3: パーサーを修正する

primarysecondary を別々に読むように書き換えます。

NSDictionary *limits = payload[@"rate_limits"] ?: @{};
NSDictionary *primaryWindow   = limits[@"primary"];
NSDictionary *secondaryWindow = limits[@"secondary"];
BOOL hasPrimaryWindow   = [primaryWindow isKindOfClass:NSDictionary.class];
BOOL hasSecondaryWindow = [secondaryWindow isKindOfClass:NSDictionary.class];
if (!hasPrimaryWindow && !hasSecondaryWindow) continue;

snapshot.available = YES;
NSDictionary *window = hasPrimaryWindow ? primaryWindow : secondaryWindow;
snapshot.hasPrimary     = YES;
snapshot.primaryPercent = [window[@"used_percent"] doubleValue];
snapshot.primaryReset   = [window[@"resets_at"] doubleValue];
snapshot.windowMinutes  = [window[@"window_minutes"] integerValue];

snapshot.hasSecondary = hasPrimaryWindow && hasSecondaryWindow;
if (snapshot.hasSecondary) {
    snapshot.secondaryPercent = [secondaryWindow[@"used_percent"] doubleValue];
    snapshot.secondaryReset   = [secondaryWindow[@"resets_at"] doubleValue];
}

意識した点が3つあります。

  • 既存の挙動を壊さない。 primary が無く secondary だけある場合は、従来どおり secondary を主枠として扱います
  • 両方ある時だけ hasSecondary を立てる。secondary を主枠に昇格させたケースで、同じデータを2枚描いてしまうのを防ぎます
  • ついでに hasPrimary も設定。従来は未設定のままでした

Sources/UsageProvider.m は1行が非常に長いので、エディタで手作業だと事故ります。私は置換スクリプトに assert を仕込んで、対象が1件でなければ何も書かずに止まるようにしました。

この時点で make build は通りますが、見た目は変わりません。UI がまだ hasSecondary を見ていないためです。

Step 4: Codex 版の UI に週間枠を追加する

Sources/main.m に手を入れます。Claude 版はカード型 UI ですが、Codex 版はラベルとバーを縦に積む構成なので、その流儀に合わせます。

4-1. プロパティを追加

@property UsageBar *bar;
@property NSTextField *weeklyPercentLabel;
@property NSTextField *weeklyResetLabel;
@property UsageBar *weeklyBar;

4-2. メニューバーの文字列を組み立てるメソッドを追加

既存の UsageStatusTitle() は値を1つしか受け取らない設計なので、NSMutableAttributedString で自前に組みます。Claude 版の menuTitleForSession:weekly:stale: がそのまま手本になります。

- (NSAttributedString *)menuTitleForPrimary:(NSNumber *)primary weekly:(NSNumber *)weekly {
    UsageSettingsStore *settings = UsageSettingsStore.sharedStore;
    NSFont *font = UsageFont(settings.menuBarFontSize, NSFontWeightBold);
    NSString *prefix = settings.showServiceName ? @"Codex " : @"";
    NSMutableAttributedString *title = [[NSMutableAttributedString alloc]
        initWithString:prefix
        attributes:@{NSFontAttributeName:font,
                     NSForegroundColorAttributeName:[settings accentColorForService:UsageServiceCodex]}];

    NSString *primaryText = primary ? UsageMenuValue(primary.doubleValue) : @"--%";
    [title appendAttributedString:[[NSAttributedString alloc]
        initWithString:primaryText
        attributes:@{NSFontAttributeName:font,
                     NSForegroundColorAttributeName:primary ? UsageColor(primary.doubleValue)
                                                            : NSColor.secondaryLabelColor}]];

    if (weekly && settings.showWeekly) {
        NSString *weeklyText = [NSString stringWithFormat:@" · W%@", UsageMenuValue(weekly.doubleValue)];
        [title appendAttributedString:[[NSAttributedString alloc]
            initWithString:weeklyText
            attributes:@{NSFontAttributeName:font,
                         NSForegroundColorAttributeName:UsageColor(weekly.doubleValue)}]];
    }
    return title;
}

Claude 版の Claude 32% · W78% に表記を揃えました。

4-3. ポップオーバーに週間行を追加

buildPopover でラベルとバーを生成し、

self.weeklyPercentLabel = [self label:@"" size:15 color:nil];
self.weeklyPercentLabel.font = ModernFont(15, NSFontWeightBold);
self.weeklyResetLabel = [self label:@"" size:12 color:NSColor.secondaryLabelColor];
self.weeklyBar = [UsageBar new];
self.weeklyBar.translatesAutoresizingMaskIntoConstraints = NO;
[self.weeklyBar.heightAnchor constraintEqualToConstant:16].active = YES;
BOOL weeklyVisible = display.showWeekly;
self.weeklyPercentLabel.hidden = !weeklyVisible;
self.weeklyBar.hidden = !weeklyVisible;
self.weeklyResetLabel.hidden = !weeklyVisible;

スタックの resetLabel の直後に挿入します。

- NSStackView *stack = [NSStackView stackViewWithViews:@[title, self.percentLabel,
-     self.periodLabel, self.bar, self.resetLabel,
-     self.tokenLabel, self.contextLabel, self.updatedLabel, buttons]];
+ NSStackView *stack = [NSStackView stackViewWithViews:@[title, self.percentLabel,
+     self.periodLabel, self.bar, self.resetLabel,
+     self.weeklyPercentLabel, self.weeklyBar, self.weeklyResetLabel,
+     self.tokenLabel, self.contextLabel, self.updatedLabel, buttons]];

行が増えるのでポップオーバーの高さと、バーの幅制約も追加します。

- controller.view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 255)];
+ controller.view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 340)];
  [self.bar.widthAnchor constraintEqualToConstant:288],
+ [self.weeklyBar.widthAnchor constraintEqualToConstant:288],

4-4. refresh で値を流し込む

メニューバーを新メソッド経由に切り替え、

- self.statusItem.button.attributedTitle = CodexTitle(UsageMenuValue(percent), UsageColor(percent));
+ NSNumber *weeklyValue = snapshot.hasSecondary ? @(snapshot.secondaryPercent) : nil;
+ self.statusItem.button.attributedTitle = [self menuTitleForPrimary:@(percent) weekly:weeklyValue];

refresh の末尾に週間表示の更新処理を足します。

BOOL showWeekly = snapshot.hasSecondary && UsageSettingsStore.sharedStore.showWeekly;
self.weeklyPercentLabel.hidden = !showWeekly;
self.weeklyBar.hidden = !showWeekly;
self.weeklyResetLabel.hidden = !showWeekly;
if (showWeekly) {
    double weeklyPercent = snapshot.secondaryPercent;
    self.weeklyBar.value = weeklyPercent;
    self.weeklyPercentLabel.stringValue = UsageSettingsStore.sharedStore.displayRemaining
        ? [NSString stringWithFormat:@"週間  %.0f%% 残り", 100 - weeklyPercent]
        : [NSString stringWithFormat:@"週間  %.0f%% 使用", weeklyPercent];
    self.weeklyPercentLabel.textColor = UsageColor(weeklyPercent);
    NSTimeInterval weeklyReset = snapshot.secondaryReset;
    self.weeklyResetLabel.stringValue = weeklyReset
        ? [NSString stringWithFormat:@"リセット: %@",
           [self formatted:[NSDate dateWithTimeIntervalSince1970:weeklyReset]]]
        : @"リセット時刻: --";
    self.weeklyResetLabel.hidden = !UsageSettingsStore.sharedStore.showResetTime;
}

データなし時のタイトル生成も新メソッドに一本化しておきます。

- if (!snapshot.available) { self.statusItem.button.attributedTitle = CodexTitle(@"--%", NSColor.secondaryLabelColor);
+ if (!snapshot.available) { self.statusItem.button.attributedTitle = [self menuTitleForPrimary:nil weekly:nil];

Step 5: 設定項目 showWeekly を追加する

ここまでのコードは UsageSettingsStore.showWeekly を参照していますが、これはまだ存在しません。既存の showResetTime を手本に追加します。

このアプリの設定項目は11箇所に分散しているので、漏れなく追加する必要があります。

Sources/UsageUI.h

  @property (nonatomic) BOOL showResetTime;
+ @property (nonatomic) BOOL showWeekly;

Sources/UsageUI.m

UserDefaults のキー定義:

  static NSString *const ShowResetTimeKey = @"display.showResetTime";
+ static NSString *const ShowWeeklyKey = @"display.showWeekly";

アクセサ (マクロで一括生成される仕組み):

  BOOL_SETTING(showResetTime, setShowResetTime, ShowResetTimeKey)
+ BOOL_SETTING(showWeekly, setShowWeekly, ShowWeeklyKey)

デフォルト値 (registerDefaults):

- ShowPercentSignKey: @YES, ShowResetTimeKey: @YES, ShowTokenUsageKey: @YES,
+ ShowPercentSignKey: @YES, ShowResetTimeKey: @YES, ShowWeeklyKey: @YES, ShowTokenUsageKey: @YES,

設定画面クラスのプロパティ宣言:

- @property NSButton *showReset; @property NSButton *showTokens;
+ @property NSButton *showReset; @property NSButton *showWeekly; @property NSButton *showTokens;

チェックボックスの生成:

self.showWeekly = [NSButton checkboxWithTitle:@"週間利用枠" target:self action:@selector(change:)];
self.showWeekly.state = s.showWeekly;

チェックボックスの配置 (detailOptions スタック):

- stackViewWithViews:@[self.showReset,self.showTokens,self.showContext,self.showModel,self.showUpdated]]
+ stackViewWithViews:@[self.showReset,self.showWeekly,self.showTokens,self.showContext,self.showModel,self.showUpdated]]

change: ハンドラ:

else if (sender == self.showWeekly) s.showWeekly = self.showWeekly.state == NSControlStateValueOn;

reloadControls:

- self.showReset.state=s.showResetTime;self.showTokens.state=s.showTokenUsage;
+ self.showReset.state=s.showResetTime;self.showWeekly.state=s.showWeekly;self.showTokens.state=s.showTokenUsage;

設定のエクスポート/インポート対象 (settingKeys と、もう一箇所のキー配列):

- ShowPercentSignKey,ShowResetTimeKey,ShowTokenUsageKey,
+ ShowPercentSignKey,ShowResetTimeKey,ShowWeeklyKey,ShowTokenUsageKey,

デフォルトを @YES にしたのは、週間枠が本来見たかった情報であることと、データが無い環境では hasSecondary が false になって自動的に隠れるためです。

Step 6: ビルドして確認

$ make build && ls dist/
$ pkill -f "ChatGPT Usage"; open "dist/ChatGPT Usage.app"

結果:

  • メニューバー: Codex 30% · W14%
  • ポップオーバー: 5時間枠の下に「週間 14% 使用」とバー、リセット日時
  • 設定 → 表示項目タブに「週間利用枠」のチェックボックス

make check でテストも回せます。Sources/provider_test.m の Codex テストは現状 primary しか検証していないので、PR にするなら secondary のケースを足すのが親切だと思います。


補足: Claude Code 版でモデル別の枠は表示できるか

同じ発想で、Claude Code 版に「Fable の週間枠」を足せないか試しました。結論は現状では不可能です。記録として残しておきます。

Claude Code の /usage 画面には週間制限が2本表示されます。

週間制限
  すべてのモデル   5% 使用
  Fable           7% 使用

ところが statusLine が渡してくる JSON の rate_limits には、次の2つしかありませんでした。

"rate_limits": {
    "five_hour": { "used_percentage": 11, "resets_at": ... },
    "seven_day": { "used_percentage": 4,  "resets_at": ... }
}

公式ドキュメントを確認しても、rate_limits のフィールドは five_hour / seven_day / spend_limit の3つだけです (spend_limit は Claude apps gateway 経由の支出上限で、モデル別の枠ではありません)。

つまり /usage の「Fable 7%」に相当するデータは statusLine には流れて来ておらず、アプリ側をどう改造しても取得元がありません。モデル別の枠を出すには Anthropic の usage API を直接叩く必要があり、これはこのアプリが掲げる「追加 API 通信をしない」という設計思想と正面から衝突します。

Codex 版との差は、データがローカルに届いているかどうかの一点でした。ここを最初に確認したおかげで、無駄な実装に踏み込まずに済んでいます。


まとめ

  • make build が clone 直後に失敗するのは、dist/ を作らずに mv しているため。mkdir -p 1行で直る
  • Codex の週間枠 (rate_limits.secondary) は JSONL に最初から入っていた。パーサーが ?: で捨てていただけ
  • UsageSnapshot には hasSecondary などの受け皿が既にあり、Claude 版が使っていた。設計が共通化されていたおかげで改造は素直だった
  • 一方 Claude 版のモデル別枠は statusLine にデータが無く、実装不可能。着手前にデータソースを確認するのが一番効く

元記事の作者は Issue や Pull Request を歓迎されているので、上記2点は還元したいと思います。軽量で気の利いたアプリなので、Codex や Claude Code を日常的に使う方は試してみてください。

改めて、元記事はこちらです。

ABOUT ME
マサ
福岡から下関へ引っ越した中年さん。 普段はWebの広告運用を生業としていて、レンタルスタジオもやっている人。 ガジェットやモノづくりが大好き。