ccc-notifier 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,179 +1,186 @@
1
1
  # ccc-notifier
2
2
 
3
- **ccc = Claude Code Cost**(現在は [Codex CLI](docs/codex.md) のコストも同じ仕組みで扱えます)。Claude Code や Codex CLI で **プロンプトを実行するたび**、そのターンにかかったコストを **$(USD)と¥(JPY)の両方** で自動通知するツールです。**5分で導入できます。**
3
+ Claude CodeCodex CLIの利用コストをターンごとに通知し、履歴とダッシュボードで見えるようにするツールです。
4
4
 
5
- ```
6
- 💰 API換算 $0.267(¥40)| Fable 5
7
- in 1.2k(cache 40%)/ out 480 · 📁 my-app · 今日: $1.85
5
+ > [!IMPORTANT]
6
+ > **表示される金額は概算です。** 実際の請求額と一致する保証はありません。「使いすぎに気づく」ための目安として使ってください。詳しくは[金額の意味](docs/cost.md)を参照してください。
7
+
8
+ ```text
9
+ 💰 API換算 $0.267(¥40) | Fable 5
10
+ in 1.2k(cache 40%) / out 480 · 📁 my-app · 今日: $1.85
8
11
  バグを直してテストを通してください
9
12
  ```
10
13
 
11
- 上が通知の例です(1行目がタイトル、2〜3行目が本文)。応答が完了するたびに OS 通知(任意で Slack 通知も)としてこれが届きます。
12
-
13
- > **⚠️ 表示される金額はあくまで概算値です。** transcript のトークン数 × 公開単価表からローカルで計算した参考値であり、Anthropic の請求額と一致することを保証するものではありません。「使いすぎに気づく」ための目安としてご利用ください(詳細は [金額の意味](docs/cost.md))。
14
-
15
14
  ![通知の実例](docs/images/notification.png)
16
15
 
17
16
  ![ダッシュボード](docs/images/dashboard.png)
18
17
 
19
- ## 特徴 / Features
18
+ ## 主な機能
20
19
 
21
- - **ターン毎に自動通知** — Claude Code / Codex CLI の応答が完了するたび(Stop hook)に、そのターンのコストを自動でプッシュ通知します。自分から `/cost` を見に行く必要はありません
22
- - **$ と ¥ を併記** — USD と JPY の両方を毎回表示します(為替レートは自動取得 + キャッシュ + 固定フォールバックの三段構え)
23
- - **プロンプト全文をローカルに履歴保存** — `~/.ccc-notifier/history.jsonl` にそのターンのプロンプト全文を保存します(外部には送信されません)
24
- - **HTMLダッシュボード** — `dashboard` コマンドで、サマリー・コスト推移(**日 / 週 / 月**で切替、横スクロールで過去まで)・モデル別/プロジェクト別内訳・検索できるターン履歴を1枚の HTML(完全自己完結・ライト/ダーク対応)に書き出してブラウザで開きます。棒をクリックするとその期間が選択され、内訳・履歴が連動(「通算」で全期間)。Codex CLI を併用していれば **Claude / Codex** のソースフィルタも使えます
25
- - **月予算(monthly budget)** — 月に使える金額(USD)を設定すると、ダッシュボードに**当月の使用額 / 予算・使用率(%)** をプログレスバーで表示します(`init` の対話、または `ccc-notifier budget <金額>` で設定。Claude Code と Codex CLI の合算です)
26
- - **Codex CLI もそのまま検出** — `init` を実行すると `~/.codex` の有無を見て、あれば Codex にも導入するか聞かれます。通知・履歴・ダッシュボード・`sweep` は Claude Code と全く同じ仕組みで扱います([詳細](docs/codex.md))
27
- - **OS 標準の通知機構のみ使用・追加依存ゼロ** — 通知は macOS では `osascript`、Windows では PowerShell 標準のトースト通知機能のみで送信します(node-notifier 等の外部通知ライブラリには一切依存しません)
28
- - **全処理ローカル・フェイルセーフ設計** — 通知や集計の処理が失敗しても、Claude Code / Codex CLI 本体の応答は絶対にブロックしません
20
+ - Claude Codeの応答完了ごとに、USD・JPYの概算をOS通知またはSlackへ送ります
21
+ - プロンプトと概算をローカルへ保存し、あとから検索・集計できます
22
+ - 日・週・月の推移やモデル別・プロジェクト別の内訳をHTMLダッシュボードで確認できます
23
+ - 月予算を設定し、今月の使用率を確認できます
24
+ - 通知を一時停止しても、履歴の記録は続けられます
25
+ - Codex CLIも任意で追加し、Claude Codeと同じダッシュボードで確認できます
29
26
 
30
- ## 必要環境 / Requirements
27
+ ## 必要なもの
31
28
 
32
- - Node.js 20 以上(未導入の場合は [Node.js の用意](docs/installing-node.md) を参照してください)
33
- - Claude Code(インストール・利用中であること。`init` は Claude Code の Stop hook を必ず設定します)
34
- - (任意)[Codex CLI](docs/codex.md) を併用している場合は、`init` が自動検出してそのコストも同じ仕組みで通知できます
29
+ - Node.js 20以上(未導入の場合は[Node.jsの用意](docs/installing-node.md)
30
+ - Claude Code(必須)
31
+ - Codex CLI(任意)
35
32
 
36
- Windows / WSL2 で使う場合は、環境ごとの手順を [Windows / WSL2 での導入](docs/windows-wsl2.md) にまとめています。
33
+ Windows / WSL2[専用の導入手順](docs/windows-wsl2.md)も参照してください。
37
34
 
38
- ## インストール / Install
35
+ ## まず試す
39
36
 
40
- ### 方法A: npm から(推奨)
37
+ グローバルインストールは不要です。最新版を一度試します。
41
38
 
42
39
  ```bash
43
- npm install -g ccc-notifier
44
- ccc-notifier init
40
+ npx ccc-notifier@latest init
45
41
  ```
46
42
 
47
- グローバルに入れず一度だけ試すなら `npx ccc-notifier init` でも実行できます。`ccc-notifier` には短縮エイリアス `cccn` もあります(`cccn doctor` など)。
43
+ 画面の質問に沿って、通知方法・金額ラベル・月予算・Codex連携を選びます。
48
44
 
49
- ### 方法B: ソースから(開発版・最新を試したいとき)
45
+ `init`がClaude Codeの`~/.claude/settings.json`を書き換える場合は、**書き換える前にタイムスタンプ付きのバックアップ**を作ります。既存の設定を解析できない場合は自動編集せず、手動設定方法を表示します。
46
+
47
+ セットアップ後にClaude Codeで何か実行してください。通知が届かない場合は次で診断できます。
50
48
 
51
49
  ```bash
52
- git clone https://github.com/shimabox/ccc-notifier.git
53
- cd ccc-notifier
54
- mise install # mise 利用時(Node.js 20 が自動で入ります)。mise が無ければ Node.js 20 以上を用意してください
55
- npm ci
56
- npm run build
57
- node dist/cli.js init
50
+ npx ccc-notifier@latest doctor
58
51
  ```
59
52
 
60
- 最後の `node dist/cli.js init` が次の「セットアップ」の内容(対話形式のセットアップ)です。
53
+ Codex連携を選んだ場合は、Codexを再起動し、表示されるhookの確認画面で承認してください。詳しくは[Codex CLI対応](docs/codex.md)を参照してください。
61
54
 
62
- ## セットアップ / Setup
55
+ <details>
56
+ <summary><strong>まず直近1週間の履歴で試す(全履歴・完全削除まで)</strong></summary>
63
57
 
64
- > **補足**: 以降に出てくる `npx ccc-notifier <command>` や `npm install -g ccc-notifier` は、npm でインストールした場合(方法A)の表記です。**方法B(ソースから)でインストールした場合は、`npx ccc-notifier <command>` を `node dist/cli.js <command>` に読み替えてください**(リポジトリのディレクトリで実行します)。
58
+ 上のセットアップが終わったら、まず直近7日分の件数と概算を事前確認します。この操作では何も変更しません。
65
59
 
66
- 1. **セットアップコマンドを実行**
60
+ ```bash
61
+ npx ccc-notifier@latest sweep --dry-run --days 7
62
+ ```
67
63
 
68
- インストール方法に応じて `init` を実行します。
64
+ 次の操作は、保存済み履歴をいったん消し、直近7日分だけを作り直します。履歴のバックアップは作りません。
69
65
 
70
- - 方法A(npm)の場合: `npx ccc-notifier@latest init`
71
- - 方法B(ソースから)の場合: `node dist/cli.js init`
66
+ ```bash
67
+ npx ccc-notifier@latest sweep --days 7
68
+ ```
72
69
 
73
- 2. **質問に答える**
70
+ 作成した7日分の履歴をダッシュボードで確認します。
74
71
 
75
- 対話形式で次の4点(Codex CLI を検出した場合はもう1点)を聞かれます。
72
+ ```bash
73
+ npx ccc-notifier@latest dashboard --days 7
74
+ ```
76
75
 
77
- - 通知チャネル(OS通知のみ / Slackのみ / OS通知+Slack / 通知なし(記録・ダッシュボードのみ))
78
- - コスト表示ラベル(API換算 / 実額)
79
- - USD/JPY のフォールバック為替レート(既定 150円)
80
- - 月の予算(USD、既定 $400。`0` で無効。ダッシュボードに当月の使用率を表示。詳細は [月予算](docs/monthly-budget.md))
81
- - (Codex CLI 検出時)Codex にもコスト通知を入れるか(既定 Yes。詳細は [Codex CLI 対応](docs/codex.md))
76
+ ### 続けて使う場合
82
77
 
83
- 完了すると Claude Code の `~/.claude/settings.json` に Stop hook が自動で追記されます。**既存の設定内容(他の hook や設定)は一切変更されず**、書き込み前に必ず `settings.json.bak-<タイムスタンプ>` としてバックアップが作成されます。settings.json が壊れている(JSONとして解析できない)場合は自動編集を諦め、手動で追記する内容を画面に表示するだけで、ファイルには一切書き込みません。
78
+ まず、全履歴を作り直した場合の件数と概算を事前確認します。
84
79
 
85
- 3. **Claude Code(や Codex CLI)で何か実行してみる**
80
+ ```bash
81
+ npx ccc-notifier@latest sweep --dry-run
82
+ ```
86
83
 
87
- ひとこと実行して応答が完了すると、通知が届きます。
84
+ 問題なければ、7日版の履歴をいったん消し、残っているデータから全履歴を作り直します。
88
85
 
89
- 届かない場合は `npx ccc-notifier doctor` で診断できます。hook登録・設定ファイル・単価表・為替レート・テスト通知・直近セッション合計などを ✅ / ⚠️ / ❌ で表示し、❌ が1つでもあれば終了コード1を返します(通知が来ないときの詳しい対処は [FAQ](docs/faq.md) 参照)。
86
+ ```bash
87
+ npx ccc-notifier@latest sweep
88
+ ```
90
89
 
91
- ### 非対話実行(CI・スクリプト向け)/ Non-interactive flags
90
+ 継続利用しやすいようにグローバルインストールし、連携先をグローバル版へ更新します。
92
91
 
93
- CI などから非対話で `init` したい場合は次のフラグが使えます。
92
+ ```bash
93
+ npm install -g ccc-notifier
94
+ ccc-notifier init
95
+ ```
94
96
 
95
- | フラグ | 説明 |
96
- |---|---|
97
- | `--yes`, `-y` | 対話プロンプトを出さずに実行(非対話には必須) |
98
- | `--os-only` | Slack を無効化し OS通知のみにする |
99
- | `--slack-webhook <url>` | Slack Incoming Webhook URL を指定して有効化 |
100
- | `--slack-only` | Slack のみにする(OS 通知を無効化)。`--slack-webhook` と併用が必須 |
101
- | `--no-notify` | 通知なし(記録・ダッシュボードのみ)。`--os-only` / `--slack-only` / `--slack-webhook` とは併用不可(例: `npx ccc-notifier init --yes --no-notify`。詳細は [設定](docs/configuration.md#通知なしモード記録ダッシュボードのみ--dashboard-only-mode)) |
102
- | `--label <api_equivalent\|actual>` | コスト表示ラベルを指定 |
103
- | `--rate <number>` | USD/JPY フォールバックレートを指定 |
104
- | `--budget <USD>` | 月予算(USD)を指定(0 で無効)。未指定なら既定 **$400**(既存設定があれば維持) |
105
- | `--codex` | Codex CLI にも Stop hook を導入する(`~/.codex` 未検出でも強制導入。詳細は [Codex CLI 対応](docs/codex.md)) |
106
- | `--no-codex` | Codex hook を導入しない(検出しても触らない)。`--codex` とは併用不可 |
107
-
108
- ## コマンド一覧 / Commands
109
-
110
- | コマンド | 説明 |
111
- |---|---|
112
- | `init` | Stop hook を対話形式でセットアップ(前述のフラグで非対話実行も可) |
113
- | `doctor` | hook登録・設定・単価表・為替・通知・直近セッション合計を診断 |
114
- | `report [--days N] [--json]` | 蓄積した履歴を集計してターミナルに表示(`--days` の既定は30、不正な値も30扱い)。`--json` で機械可読な出力 |
115
- | `dashboard [--all\|--days N] [--no-open] [--out <path>] [--refresh <sec>\|--no-refresh]` | 履歴を可視化した HTML ダッシュボードを生成してブラウザで開く(引数なしは設定期間の直近版 `report.html`、`--all` は全履歴版 `report-all.html`。詳細は [ダッシュボード](docs/dashboard.md)) |
116
- | `sweep [--dry-run] [--days N] [--include-active]` | 過去の未計上分(hook 導入前や後から完了したサブエージェント分)を一括で履歴に取り込む。ローカル走査のみで **Claude API を呼ばず料金ゼロ**・二重計上なし(詳細は [過去分の取り込み](docs/sweep.md)) |
117
- | `history <clear\|redact> [--days N] [--yes]` | 履歴(`history.jsonl`)を削除。`clear` はレコードごと、`redact` はプロンプト全文だけ消去。`--days N` で「N 日より前」だけ対象(詳細は [履歴の削除](docs/dashboard.md#履歴の削除--deleting-history)) |
118
- | `budget [<USD>]` | 月予算(USD)の表示/設定。金額省略で現在の予算と当月の使用率を表示、`budget 400` で設定、`budget 0` で解除(詳細は [月予算](docs/monthly-budget.md)) |
119
- | `mute [30m\|2h\|1d]` | 通知(OS/Slack)を一時停止する。期間省略で無期限、`30m`/`2h`/`1d` で期限付き。**停止中もコスト記録とダッシュボード更新は続きます**(詳細は [設定 / mute](docs/configuration.md#通知の一時停止と再開--pausing--resuming-notifications)) |
120
- | `unmute` | 停止した通知を再開する |
121
- | `uninstall [--purge] [--yes]` | Stop hook を削除。`--purge` を付けると `~/.ccc-notifier` のデータ(設定・履歴・キャッシュ)も削除 |
122
- | `track` | Stop hook から自動的に呼ばれる**内部コマンド**。stdin 経由で JSON を受け取ります。手動実行は不要です |
123
- | `--version`, `-v` | バージョン表示 |
124
- | `--help`, `-h` | ヘルプ表示 |
125
-
126
- `report --json` は次のような形の JSON を出力します(スクリプト等への取り込み用)。
127
-
128
- ```json
129
- {
130
- "days": 30,
131
- "daily": [{ "date": "2026-07-06", "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63 }],
132
- "byModel": { "claude-fable-5": { "turns": 2, "costUSD": 0.3, "costJPY": 45 }, "claude-sonnet-5": { "turns": 1, "costUSD": 0.03, "costJPY": 4.5 } },
133
- "total": { "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63, "subagentsUSD": 0.03 }
134
- }
97
+ ### 使わない場合
98
+
99
+ この時点ではグローバル版を入れていないため、npxでccc-notifierのhook・設定・履歴・キャッシュを削除します。
100
+
101
+ ```bash
102
+ npx ccc-notifier@latest uninstall --yes --purge
135
103
  ```
136
104
 
137
- 金額(`costUSD`・`costJPY`)は**サブエージェント分を含む総額**です。`total.subagentsUSD` はそのうちサブエージェントが占める金額(なければ 0)、`byModel` にはサブエージェントが使ったモデルも含まれます。
105
+ 安全のため作成した`settings.json.bak-*`などのバックアップは残ります。不要なら内容を確認してから手動で削除してください。
138
106
 
139
- ## ダッシュボード / Dashboard
107
+ </details>
140
108
 
141
- `dashboard` コマンドで、サマリー(今日 / 今週 / 今月 / 通算)、コスト推移(日/週/月で切替・横スクロールで過去まで)、モデル別/プロジェクト別内訳、検索・行展開できるターン履歴を HTML に書き出してブラウザで開きます。自動生成物は、毎ターン更新する直近版 `~/.ccc-notifier/report.html`(既定30日)と、ローカル日の最初の正常なターンだけ更新する全履歴版 `~/.ccc-notifier/report-all.html` に分かれます。片方が未生成でも生成方法を示すplaceholderを置くため、ページ内リンクは切れません。履歴・カーソル・生成snapshotは `cache/data.lock/` で直列化されます。履歴の読み込みと解析は正確な当月予算を保つため全履歴が対象です。生成物は **CSS/JS/SVG をすべてインライン化した完全自己完結・オフライン動作・外部通信ゼロ**のファイルで、ライト/ダーク両対応です。
109
+ ## 気に入ったらグローバルインストール
142
110
 
143
111
  ```bash
144
- npx ccc-notifier dashboard # 設定期間(既定30日)の直近版 report.html を生成して開く
145
- npx ccc-notifier dashboard --all # 全履歴版 report-all.html を生成して開く
146
- npx ccc-notifier dashboard --days 7 # 直近7日の report.html を生成して開く
112
+ npm install -g ccc-notifier
113
+ ccc-notifier init
147
114
  ```
148
115
 
149
- グラフの棒をクリックするとその期間に内訳・履歴が連動し、全履歴版では「通算」、期間限定版では「対象期間合計」で埋め込まれた全期間に戻せます。直近版は応答完了ごと、全履歴版は1日1回(または手動 `dashboard --all`)更新されます。Codex CLI のレコードがあれば **Claude / Codex** を絞り込むソースフィルタも表示されます(詳細は [Codex CLI 対応](docs/codex.md))。検索・行クリックでプロンプト全文を確認できます:
116
+ 以降は`ccc-notifier doctor`のように短く実行できます。`cccn`も同じコマンドとして使えます。
117
+
118
+ ## よく使うコマンド
119
+
120
+ 以下はグローバルインストール後の表記です。npxで使う場合は`ccc-notifier`を`npx ccc-notifier@latest`に置き換えてください。
121
+
122
+ | コマンド | できること |
123
+ |---|---|
124
+ | `ccc-notifier init` | 通知や連携を設定する |
125
+ | `ccc-notifier doctor` | 設定と通知を診断する |
126
+ | `ccc-notifier report [--days N]` | コスト集計をターミナルに表示する |
127
+ | `ccc-notifier dashboard` | 直近のHTMLダッシュボードを開く |
128
+ | `ccc-notifier dashboard --all` | 保存済みの全履歴版を開く |
129
+ | `ccc-notifier budget [<USD>]` | 月予算を確認・設定する(`0`で解除) |
130
+ | `ccc-notifier mute [30m\|2h\|1d]` | 通知を一時停止する(期間省略で無期限) |
131
+ | `ccc-notifier unmute` | 通知を再開する |
132
+ | `ccc-notifier sweep --dry-run [--days N]` | 履歴を作り直した場合の件数と概算を確認する |
133
+ | `ccc-notifier sweep [--days N]` | 残っている利用データから履歴を作り直す |
134
+
135
+ > [!CAUTION]
136
+ > **`sweep`は履歴を作り直すコマンドです。** 保存済みの履歴をいったん消し、Claude Code / Codex CLIに残っているデータから再作成します。設定や通知は消えませんが、履歴のバックアップは作りません。以前に削除・伏せ字にした履歴も、Claude Code / Codex CLI側の元データに残っていれば再び入ります。また、再作成した時点の単価と為替を使うため、以前の金額から変わることがあります。先に`--dry-run`で確認してください。詳しくは[履歴の再生成](docs/sweep.md)を参照してください。
137
+
138
+ ダッシュボード、履歴の削除、通知なしモードなど、その他の操作は[ドキュメント](#ドキュメント)または`ccc-notifier --help`で確認できます。
139
+
140
+ ## アップデート
141
+
142
+ npxで使う場合は、各コマンドに`@latest`を付ければ常に最新版が使われるため、別の更新作業は不要です。
143
+
144
+ グローバルインストールの場合は、次のコマンドだけで更新できます。
145
+
146
+ ```bash
147
+ npm update -g ccc-notifier
148
+ ```
149
+
150
+ hook設定の更新が必要なリリースだけ、リリース案内に従って`init`を実行してください。Codexの更新手順は[Codex CLI対応](docs/codex.md)にまとめています。
151
+
152
+ ## 完全に削除する
153
+
154
+ グローバルインストールした場合は、ccc-notifierのhookと保存データを削除してからパッケージを削除します。
155
+
156
+ ```bash
157
+ ccc-notifier uninstall --yes --purge
158
+ npm uninstall -g ccc-notifier
159
+ ```
150
160
 
151
- ![ターン履歴(検索と全文展開)](docs/images/history-expand.png)
161
+ npxだけで使っていた場合は、最初のコマンドを`npx ccc-notifier@latest uninstall --yes --purge`に置き換え、`npm uninstall -g`は不要です。安全のため作成した`settings.json.bak-*`などのバックアップは自動削除しません。
152
162
 
153
- 期間の連動・自動更新・月予算表示・履歴の削除など、詳しくは [ダッシュボード](docs/dashboard.md) を参照してください。
163
+ ## プライバシー
154
164
 
155
- ## プライバシー / Privacy
165
+ - 履歴とプロンプトは`~/.ccc-notifier`にローカル保存します
166
+ - Slackを設定した場合だけ、概算・トークン情報とプロンプトを指定したSlack Webhookへ送ります。既定はプロンプト冒頭100字で、設定により文字数の変更や全文送信もできます
167
+ - 単価表と為替レートの取得時に外部通信しますが、プロンプトやコードは送りません
156
168
 
157
- - プロンプトの全文は **ローカルの `~/.ccc-notifier/history.jsonl` にのみ** 保存されます
158
- - OS通知に表示されるプロンプトは、ローカル上で先頭50字程度に切り詰めたものです
159
- - Slack を設定した場合のみ、既定でプロンプト冒頭100字(`sendFullPrompt` で文字数変更・全文送信も可能)がその Slack Webhook 宛に送信されます
160
- - それ以外に外部へ送信されるのは次の2種類の API 呼び出しだけです。いずれもプロンプトやコードの内容を一切含まない、レート・価格を取得するだけのリクエストです
161
- - 為替レート取得([frankfurter.dev](https://frankfurter.dev/) → 失敗時は [open.er-api.com](https://open.er-api.com/))
162
- - 単価表取得([LiteLLM の公開JSON](https://github.com/BerriAI/litellm))
169
+ 詳しくは[Slack通知](docs/slack.md)と[仕組み](docs/how-it-works.md)を参照してください。
163
170
 
164
- ## ドキュメント / Documentation
171
+ ## ドキュメント
165
172
 
166
- - [Node.js の用意](docs/installing-node.md) — Node.js 20 が未導入の方へ(mise / 公式インストーラ)
167
- - [Windows / WSL2 での導入](docs/windows-wsl2.md) — ネイティブ Windows と WSL2 での手順
168
- - [Codex CLI 対応](docs/codex.md) — 導入・hook の信頼承認・仕組み・制限
169
- - [ダッシュボード](docs/dashboard.md) — 期間の連動・自動更新・履歴の削除
170
- - [月予算 / Monthly budget](docs/monthly-budget.md) — 当月の使用率表示
171
- - [過去分の取り込み / sweep](docs/sweep.md) — hook 導入前・後から完了した分の回収
172
- - [金額の意味](docs/cost.md) — ラベルの意味と、概算値である理由
173
- - [設定 / Configuration](docs/configuration.md) — `config.json` の全キーと通知の一時停止(mute)
174
- - [Slack 通知の有効化](docs/slack.md) — Incoming Webhook の設定
175
- - [仕組み / How it Works](docs/how-it-works.md) — Stop hook から通知までの流れ
176
- - [よくある質問 / FAQ](docs/faq.md) — 通知が来ない・`/cost` との差など
173
+ - [Node.jsの用意](docs/installing-node.md)
174
+ - [Windows / WSL2での導入](docs/windows-wsl2.md)
175
+ - [Codex CLI対応](docs/codex.md)
176
+ - [Slack通知](docs/slack.md)
177
+ - [設定・通知の一時停止](docs/configuration.md)
178
+ - [金額の意味](docs/cost.md)
179
+ - [ダッシュボードと履歴の削除](docs/dashboard.md)
180
+ - [月予算](docs/monthly-budget.md)
181
+ - [履歴の再生成(sweep)](docs/sweep.md)
182
+ - [仕組み](docs/how-it-works.md)
183
+ - [よくある質問](docs/faq.md)
177
184
 
178
185
  ## License
179
186
 
@@ -7,7 +7,7 @@ import {
7
7
  currentMonthTotals,
8
8
  paths,
9
9
  readConfig
10
- } from "./chunk-26CISNOE.js";
10
+ } from "./chunk-OOAC5ULQ.js";
11
11
 
12
12
  // src/budget.ts
13
13
  import { writeFileSync } from "fs";
@@ -1,19 +1,100 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  paths
4
- } from "./chunk-26CISNOE.js";
4
+ } from "./chunk-OOAC5ULQ.js";
5
5
 
6
- // src/data-lock.ts
7
- import { randomUUID } from "crypto";
8
- import { hostname } from "os";
6
+ // src/dashboard-state.ts
9
7
  import {
8
+ closeSync,
10
9
  existsSync,
11
- mkdirSync,
10
+ openSync,
12
11
  readFileSync,
12
+ readSync,
13
13
  renameSync,
14
14
  rmSync,
15
15
  writeFileSync
16
16
  } from "fs";
17
+ import { randomUUID } from "crypto";
18
+ function localDate(now) {
19
+ const y = now.getFullYear();
20
+ const m = String(now.getMonth() + 1).padStart(2, "0");
21
+ const d = String(now.getDate()).padStart(2, "0");
22
+ return `${y}-${m}-${d}`;
23
+ }
24
+ function timeZone() {
25
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
26
+ }
27
+ function makeFullDashboardState(now = /* @__PURE__ */ new Date()) {
28
+ return { localDate: localDate(now), timeZone: timeZone(), generatedAt: now.toISOString() };
29
+ }
30
+ function readState() {
31
+ const file = paths().dashboardFullStateFile;
32
+ if (!existsSync(file)) return null;
33
+ try {
34
+ const value = JSON.parse(readFileSync(file, "utf8"));
35
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
36
+ const v = value;
37
+ if (typeof v.localDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v.localDate) || typeof v.timeZone !== "string" || v.timeZone.length === 0 || typeof v.generatedAt !== "string" || !Number.isFinite(Date.parse(v.generatedAt))) {
38
+ return null;
39
+ }
40
+ return v;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ function isFullDashboardDue(now = /* @__PURE__ */ new Date()) {
46
+ const p = paths();
47
+ if (!existsSync(p.fullDashboardFile)) return true;
48
+ try {
49
+ const fd = openSync(p.fullDashboardFile, "r");
50
+ try {
51
+ const head = Buffer.alloc(512);
52
+ const n = readSync(fd, head, 0, head.length, 0);
53
+ if (head.toString("utf8", 0, n).includes('name="cccn-placeholder"')) return true;
54
+ } finally {
55
+ closeSync(fd);
56
+ }
57
+ } catch {
58
+ return true;
59
+ }
60
+ const state = readState();
61
+ if (state === null) return true;
62
+ const expected = makeFullDashboardState(now);
63
+ if (state.timeZone !== expected.timeZone) return true;
64
+ if (state.localDate !== expected.localDate) return true;
65
+ if (state.localDate > expected.localDate) return true;
66
+ if (Date.parse(state.generatedAt) > now.getTime()) return true;
67
+ return false;
68
+ }
69
+ function writeFullDashboardStateAtomic(state) {
70
+ const file = paths().dashboardFullStateFile;
71
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
72
+ try {
73
+ writeFileSync(tmp, `${JSON.stringify(state)}
74
+ `, "utf8");
75
+ renameSync(tmp, file);
76
+ } finally {
77
+ rmSync(tmp, { force: true });
78
+ }
79
+ }
80
+ function invalidateCanonicalDashboards() {
81
+ const p = paths();
82
+ for (const file of [p.recentDashboardFile, p.fullDashboardFile, p.dashboardFullStateFile]) {
83
+ rmSync(file, { force: true });
84
+ }
85
+ }
86
+
87
+ // src/data-lock.ts
88
+ import { randomUUID as randomUUID2 } from "crypto";
89
+ import { hostname } from "os";
90
+ import {
91
+ existsSync as existsSync2,
92
+ mkdirSync,
93
+ readFileSync as readFileSync2,
94
+ renameSync as renameSync2,
95
+ rmSync as rmSync2,
96
+ writeFileSync as writeFileSync2
97
+ } from "fs";
17
98
  import { join } from "path";
18
99
  var DATA_LOCK_LEASE_MS = 3e4;
19
100
  var HEARTBEAT_MS = 5e3;
@@ -22,7 +103,7 @@ function ownerFile(dir) {
22
103
  }
23
104
  function readOwner(dir) {
24
105
  try {
25
- const v = JSON.parse(readFileSync(ownerFile(dir), "utf8"));
106
+ const v = JSON.parse(readFileSync2(ownerFile(dir), "utf8"));
26
107
  if (typeof v.token !== "string" || typeof v.pid !== "number" || typeof v.hostname !== "string" || typeof v.acquiredAt !== "string" || typeof v.heartbeatAt !== "string") return null;
27
108
  return v;
28
109
  } catch {
@@ -30,21 +111,21 @@ function readOwner(dir) {
30
111
  }
31
112
  }
32
113
  function writeOwner(dir, owner) {
33
- const tmp = join(dir, `owner.${owner.token}.${randomUUID()}.tmp`);
114
+ const tmp = join(dir, `owner.${owner.token}.${randomUUID2()}.tmp`);
34
115
  try {
35
- writeFileSync(tmp, `${JSON.stringify(owner)}
116
+ writeFileSync2(tmp, `${JSON.stringify(owner)}
36
117
  `, "utf8");
37
- renameSync(tmp, ownerFile(dir));
118
+ renameSync2(tmp, ownerFile(dir));
38
119
  } finally {
39
- rmSync(tmp, { force: true });
120
+ rmSync2(tmp, { force: true });
40
121
  }
41
122
  }
42
123
  function quarantineOwned(dir, token, label) {
43
124
  const before = readOwner(dir);
44
125
  if (before?.token !== token) return false;
45
- const quarantine = `${dir}.${label}-${token}-${randomUUID()}`;
126
+ const quarantine = `${dir}.${label}-${token}-${randomUUID2()}`;
46
127
  try {
47
- renameSync(dir, quarantine);
128
+ renameSync2(dir, quarantine);
48
129
  } catch {
49
130
  return false;
50
131
  }
@@ -52,21 +133,21 @@ function quarantineOwned(dir, token, label) {
52
133
  if (moved?.token !== token) {
53
134
  return false;
54
135
  }
55
- rmSync(quarantine, { recursive: true, force: true });
136
+ rmSync2(quarantine, { recursive: true, force: true });
56
137
  return true;
57
138
  }
58
139
  function claimDir(fixed, label, now, metadataWriter = writeOwner) {
59
- const token = randomUUID();
140
+ const token = randomUUID2();
60
141
  const staging = `${fixed}.${label}-${token}`;
61
142
  const iso = now.toISOString();
62
143
  const owner = { token, pid: process.pid, hostname: hostname(), acquiredAt: iso, heartbeatAt: iso };
63
144
  try {
64
145
  mkdirSync(staging);
65
146
  metadataWriter(staging, owner);
66
- renameSync(staging, fixed);
147
+ renameSync2(staging, fixed);
67
148
  return { token, owner };
68
149
  } catch {
69
- rmSync(staging, { recursive: true, force: true });
150
+ rmSync2(staging, { recursive: true, force: true });
70
151
  return null;
71
152
  }
72
153
  }
@@ -99,15 +180,15 @@ function tryReclaim(now, leaseMs) {
99
180
  if (!processDefinitelyDead(first.pid)) return false;
100
181
  const second = readOwner(p.dataLockDir);
101
182
  if (second === null || second.token !== first.token || second.heartbeatAt !== first.heartbeatAt) return false;
102
- const orphan = `${p.dataLockDir}.orphan-${first.token}-${randomUUID()}`;
183
+ const orphan = `${p.dataLockDir}.orphan-${first.token}-${randomUUID2()}`;
103
184
  try {
104
- renameSync(p.dataLockDir, orphan);
185
+ renameSync2(p.dataLockDir, orphan);
105
186
  } catch {
106
187
  return false;
107
188
  }
108
189
  const moved = readOwner(orphan);
109
190
  if (moved?.token !== first.token || moved.heartbeatAt !== first.heartbeatAt) return false;
110
- rmSync(orphan, { recursive: true, force: true });
191
+ rmSync2(orphan, { recursive: true, force: true });
111
192
  return true;
112
193
  } finally {
113
194
  quarantineOwned(p.dataReclaimDir, guard.token, "released");
@@ -117,15 +198,15 @@ function acquireDataLock(opts = {}) {
117
198
  const now = opts.now ?? /* @__PURE__ */ new Date();
118
199
  const leaseMs = opts.leaseMs ?? DATA_LOCK_LEASE_MS;
119
200
  const p = paths();
120
- if (existsSync(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
201
+ if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
121
202
  let claim = claimDir(p.dataLockDir, "acquire", now, opts.metadataWriter);
122
203
  if (claim === null) {
123
204
  if (!tryReclaim(now, leaseMs)) return null;
124
- if (existsSync(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
205
+ if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
125
206
  claim = claimDir(p.dataLockDir, "acquire", now, opts.metadataWriter);
126
207
  if (claim === null) return null;
127
208
  }
128
- if (existsSync(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) {
209
+ if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) {
129
210
  quarantineOwned(p.dataLockDir, claim.token, "yielded");
130
211
  return null;
131
212
  }
@@ -168,5 +249,9 @@ async function waitForDataLock(timeoutMs, pollMs = 25) {
168
249
  }
169
250
 
170
251
  export {
252
+ makeFullDashboardState,
253
+ isFullDashboardDue,
254
+ writeFullDashboardStateAtomic,
255
+ invalidateCanonicalDashboards,
171
256
  waitForDataLock
172
257
  };