ccc-notifier 0.1.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/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/budget-5H2GQRXH.js +71 -0
- package/dist/chunk-4JNZ7BHO.js +276 -0
- package/dist/chunk-64N5SGTT.js +1024 -0
- package/dist/chunk-DGXUSPS4.js +21 -0
- package/dist/chunk-IIYMGLV4.js +309 -0
- package/dist/chunk-KUJZYZSG.js +66 -0
- package/dist/chunk-L5MLVTA2.js +393 -0
- package/dist/chunk-OEG3AVU6.js +436 -0
- package/dist/chunk-TB5A7U7G.js +82 -0
- package/dist/chunk-TBFKGFZX.js +87 -0
- package/dist/cli.js +717 -0
- package/dist/dashboard-LKK6NOBN.js +14 -0
- package/dist/history-MWFHO5VR.js +114 -0
- package/dist/mute-2JW3NQXN.js +12 -0
- package/dist/setup-RT62VO37.js +15 -0
- package/dist/sweep-XKKODGMA.js +460 -0
- package/dist/track-PBHTVZ3L.js +168 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shimabox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ccc-notifier
|
|
2
|
+
|
|
3
|
+
**ccc = Claude Code Cost.** Claude Code で **プロンプトを実行するたび**、そのターンにかかったコストを **$(USD)と¥(JPY)の両方** で自動通知するツールです。**5分で導入できます。**
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
💰 API換算 $0.267(¥40)| Fable 5
|
|
7
|
+
in 1.2k(cache 40%)/ out 480 · 📁 my-app · 今日: $1.85
|
|
8
|
+
バグを直してテストを通してください
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
上が通知の例です(1行目がタイトル、2〜3行目が本文)。応答が完了するたびに OS 通知(任意で Slack 通知も)としてこれが届きます。
|
|
12
|
+
|
|
13
|
+
> **⚠️ 表示される金額はあくまで概算値です。** transcript のトークン数 × 公開単価表からローカルで計算した参考値であり、Anthropic の請求額と一致することを保証するものではありません。「使いすぎに気づく」ための目安としてご利用ください(詳細は [金額の意味](docs/cost.md))。
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
## 特徴 / Features
|
|
20
|
+
|
|
21
|
+
- **ターン毎に自動通知** — Claude Code の応答が完了するたび(Stop hook)に、そのターンのコストを自動でプッシュ通知します。自分から `/cost` を見に行く必要はありません
|
|
22
|
+
- **$ と ¥ を併記** — USD と JPY の両方を毎回表示します(為替レートは自動取得 + キャッシュ + 固定フォールバックの三段構え)
|
|
23
|
+
- **プロンプト全文をローカルに履歴保存** — `~/.ccc-notifier/history.jsonl` にそのターンのプロンプト全文を保存します(外部には送信されません)
|
|
24
|
+
- **HTMLダッシュボード** — `dashboard` コマンドで、サマリー・コスト推移(**日 / 週 / 月**で切替、横スクロールで過去まで)・モデル別/プロジェクト別内訳・検索できるターン履歴を1枚の HTML(完全自己完結・ライト/ダーク対応)に書き出してブラウザで開きます。棒をクリックするとその期間が選択され、内訳・履歴が連動(「通算」で全期間)
|
|
25
|
+
- **月予算(monthly budget)** — 月に使える金額(USD)を設定すると、ダッシュボードに**当月の使用額 / 予算・使用率(%)**をプログレスバーで表示します(`init` の対話、または `ccc-notifier budget <金額>` で設定)
|
|
26
|
+
- **OS 標準の通知機構のみ使用・追加依存ゼロ** — 通知は macOS では `osascript`、Windows では PowerShell 標準のトースト通知機能のみで送信します(node-notifier 等の外部通知ライブラリには一切依存しません)
|
|
27
|
+
- **全処理ローカル・フェイルセーフ設計** — 通知や集計の処理が失敗しても、Claude Code 本体の応答は絶対にブロックしません
|
|
28
|
+
|
|
29
|
+
## 必要環境 / Requirements
|
|
30
|
+
|
|
31
|
+
- Node.js 20 以上(未導入の場合は [Node.js の用意](docs/installing-node.md) を参照してください)
|
|
32
|
+
- Claude Code(インストール・利用中であること)
|
|
33
|
+
|
|
34
|
+
Windows / WSL2 で使う場合は、環境ごとの手順を [Windows / WSL2 での導入](docs/windows-wsl2.md) にまとめています。
|
|
35
|
+
|
|
36
|
+
## インストール / Install
|
|
37
|
+
|
|
38
|
+
### 方法A: npm から(推奨)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g ccc-notifier
|
|
42
|
+
ccc-notifier init
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
グローバルに入れず一度だけ試すなら `npx ccc-notifier init` でも実行できます。`ccc-notifier` には短縮エイリアス `cccn` もあります(`cccn doctor` など)。
|
|
46
|
+
|
|
47
|
+
### 方法B: ソースから(開発版・最新を試したいとき)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/shimabox/ccc-notifier.git
|
|
51
|
+
cd ccc-notifier
|
|
52
|
+
mise install # mise 利用時(Node.js 20 が自動で入ります)。mise が無ければ Node.js 20 以上を用意してください
|
|
53
|
+
npm ci
|
|
54
|
+
npm run build
|
|
55
|
+
node dist/cli.js init
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
最後の `node dist/cli.js init` が次の「セットアップ」の内容(対話形式のセットアップ)です。
|
|
59
|
+
|
|
60
|
+
## セットアップ / Setup
|
|
61
|
+
|
|
62
|
+
> **補足**: 以降に出てくる `npx ccc-notifier <command>` や `npm install -g ccc-notifier` は、npm でインストールした場合(方法A)の表記です。**方法B(ソースから)でインストールした場合は、`npx ccc-notifier <command>` を `node dist/cli.js <command>` に読み替えてください**(リポジトリのディレクトリで実行します)。
|
|
63
|
+
|
|
64
|
+
1. **セットアップコマンドを実行**
|
|
65
|
+
|
|
66
|
+
インストール方法に応じて `init` を実行します。
|
|
67
|
+
|
|
68
|
+
- 方法A(npm)の場合: `npx ccc-notifier@latest init`
|
|
69
|
+
- 方法B(ソースから)の場合: `node dist/cli.js init`
|
|
70
|
+
|
|
71
|
+
2. **質問に答える**
|
|
72
|
+
|
|
73
|
+
対話形式で次の4点を聞かれます。
|
|
74
|
+
|
|
75
|
+
- 通知チャネル(OS通知のみ / Slackのみ / OS通知+Slack)
|
|
76
|
+
- コスト表示ラベル(API換算 / 実額)
|
|
77
|
+
- USD/JPY のフォールバック為替レート(既定 150円)
|
|
78
|
+
- 月の予算(USD、既定 $400。`0` で無効。ダッシュボードに当月の使用率を表示。詳細は [月予算](docs/monthly-budget.md))
|
|
79
|
+
|
|
80
|
+
完了すると Claude Code の `~/.claude/settings.json` に Stop hook が自動で追記されます。**既存の設定内容(他の hook や設定)は一切変更されず**、書き込み前に必ず `settings.json.bak-<タイムスタンプ>` としてバックアップが作成されます。settings.json が壊れている(JSONとして解析できない)場合は自動編集を諦め、手動で追記する内容を画面に表示するだけで、ファイルには一切書き込みません。
|
|
81
|
+
|
|
82
|
+
3. **Claude Code で何か実行してみる**
|
|
83
|
+
|
|
84
|
+
ひとこと実行して応答が完了すると、通知が届きます。
|
|
85
|
+
|
|
86
|
+
届かない場合は `npx ccc-notifier doctor` で診断できます。hook登録・設定ファイル・単価表・為替レート・テスト通知・直近セッション合計などを ✅ / ⚠️ / ❌ で表示し、❌ が1つでもあれば終了コード1を返します(通知が来ないときの詳しい対処は [FAQ](docs/faq.md) 参照)。
|
|
87
|
+
|
|
88
|
+
### 非対話実行(CI・スクリプト向け)/ Non-interactive flags
|
|
89
|
+
|
|
90
|
+
CI などから非対話で `init` したい場合は次のフラグが使えます。
|
|
91
|
+
|
|
92
|
+
| フラグ | 説明 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `--yes`, `-y` | 対話プロンプトを出さずに実行(非対話には必須) |
|
|
95
|
+
| `--os-only` | Slack を無効化し OS通知のみにする |
|
|
96
|
+
| `--slack-webhook <url>` | Slack Incoming Webhook URL を指定して有効化 |
|
|
97
|
+
| `--slack-only` | Slack のみにする(OS 通知を無効化)。`--slack-webhook` と併用が必須 |
|
|
98
|
+
| `--label <api_equivalent\|actual>` | コスト表示ラベルを指定 |
|
|
99
|
+
| `--rate <number>` | USD/JPY フォールバックレートを指定 |
|
|
100
|
+
| `--budget <USD>` | 月予算(USD)を指定(0 で無効)。未指定なら既定 **$400**(既存設定があれば維持) |
|
|
101
|
+
|
|
102
|
+
## コマンド一覧 / Commands
|
|
103
|
+
|
|
104
|
+
| コマンド | 説明 |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `init` | Stop hook を対話形式でセットアップ(前述のフラグで非対話実行も可) |
|
|
107
|
+
| `doctor` | hook登録・設定・単価表・為替・通知・直近セッション合計を診断 |
|
|
108
|
+
| `report [--days N] [--json]` | 蓄積した履歴を集計してターミナルに表示(`--days` の既定は30、不正な値も30扱い)。`--json` で機械可読な出力 |
|
|
109
|
+
| `dashboard [--days N] [--no-open] [--out <path>] [--refresh <sec>\|--no-refresh]` | 履歴を可視化した HTML ダッシュボードを生成してブラウザで開く(既定で全履歴を埋め込み、日/週/月・通算をブラウザ側で切替。詳細は [ダッシュボード](docs/dashboard.md)) |
|
|
110
|
+
| `sweep [--dry-run] [--days N] [--include-active]` | 過去の未計上分(hook 導入前や後から完了したサブエージェント分)を一括で履歴に取り込む。ローカル走査のみで **Claude API を呼ばず料金ゼロ**・二重計上なし(詳細は [過去分の取り込み](docs/sweep.md)) |
|
|
111
|
+
| `history <clear\|redact> [--days N] [--yes]` | 履歴(`history.jsonl`)を削除。`clear` はレコードごと、`redact` はプロンプト全文だけ消去。`--days N` で「N 日より前」だけ対象(詳細は [履歴の削除](docs/dashboard.md#履歴の削除--deleting-history)) |
|
|
112
|
+
| `budget [<USD>]` | 月予算(USD)の表示/設定。金額省略で現在の予算と当月の使用率を表示、`budget 400` で設定、`budget 0` で解除(詳細は [月予算](docs/monthly-budget.md)) |
|
|
113
|
+
| `mute [30m\|2h\|1d]` | 通知(OS/Slack)を一時停止する。期間省略で無期限、`30m`/`2h`/`1d` で期限付き。**停止中もコスト記録とダッシュボード更新は続きます**(詳細は [設定 / mute](docs/configuration.md#通知の一時停止と再開--pausing--resuming-notifications)) |
|
|
114
|
+
| `unmute` | 停止した通知を再開する |
|
|
115
|
+
| `uninstall [--purge] [--yes]` | Stop hook を削除。`--purge` を付けると `~/.ccc-notifier` のデータ(設定・履歴・キャッシュ)も削除 |
|
|
116
|
+
| `track` | Stop hook から自動的に呼ばれる**内部コマンド**。stdin 経由で JSON を受け取ります。手動実行は不要です |
|
|
117
|
+
| `--version`, `-v` | バージョン表示 |
|
|
118
|
+
| `--help`, `-h` | ヘルプ表示 |
|
|
119
|
+
|
|
120
|
+
`report --json` は次のような形の JSON を出力します(スクリプト等への取り込み用)。
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"days": 30,
|
|
125
|
+
"daily": [{ "date": "2026-07-06", "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63 }],
|
|
126
|
+
"byModel": { "claude-fable-5": { "turns": 2, "costUSD": 0.3, "costJPY": 45 }, "claude-sonnet-5": { "turns": 1, "costUSD": 0.03, "costJPY": 4.5 } },
|
|
127
|
+
"total": { "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63, "subagentsUSD": 0.03 }
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
金額(`costUSD`・`costJPY`)は**サブエージェント分を含む総額**です。`total.subagentsUSD` はそのうちサブエージェントが占める金額(なければ 0)、`byModel` にはサブエージェントが使ったモデルも含まれます。
|
|
132
|
+
|
|
133
|
+
## ダッシュボード / Dashboard
|
|
134
|
+
|
|
135
|
+
`dashboard` コマンドで、サマリー(今日 / 今週 / 今月 / 通算)、コスト推移(日/週/月で切替・横スクロールで過去まで)、モデル別/プロジェクト別内訳、検索・行展開できるターン履歴を1枚の HTML に書き出してブラウザで開きます。生成物は **CSS/JS/SVG をすべてインライン化した完全自己完結・オフライン動作・外部通信ゼロ**のファイルで、ライト/ダーク両対応です。
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx ccc-notifier dashboard # 生成してブラウザで開く
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
グラフの棒をクリックするとその期間に内訳・履歴が連動し、「通算」で全期間に戻せます。Claude Code の応答完了ごとに自動再生成され、開きっぱなしのタブは約30秒ごとに最新化されます。検索・行クリックでプロンプト全文を確認できます:
|
|
142
|
+
|
|
143
|
+

|
|
144
|
+
|
|
145
|
+
期間の連動・自動更新・月予算表示・履歴の削除など、詳しくは [ダッシュボード](docs/dashboard.md) を参照してください。
|
|
146
|
+
|
|
147
|
+
## プライバシー / Privacy
|
|
148
|
+
|
|
149
|
+
- プロンプトの全文は **ローカルの `~/.ccc-notifier/history.jsonl` にのみ** 保存されます
|
|
150
|
+
- OS通知に表示されるプロンプトは、ローカル上で先頭50字程度に切り詰めたものです
|
|
151
|
+
- Slack を設定した場合のみ、既定でプロンプト冒頭100字(`sendFullPrompt` で文字数変更・全文送信も可能)がその Slack Webhook 宛に送信されます
|
|
152
|
+
- それ以外に外部へ送信されるのは次の2種類の API 呼び出しだけです。いずれもプロンプトやコードの内容を一切含まない、レート・価格を取得するだけのリクエストです
|
|
153
|
+
- 為替レート取得([frankfurter.dev](https://frankfurter.dev/) → 失敗時は [open.er-api.com](https://open.er-api.com/))
|
|
154
|
+
- 単価表取得([LiteLLM の公開JSON](https://github.com/BerriAI/litellm))
|
|
155
|
+
|
|
156
|
+
## ドキュメント / Documentation
|
|
157
|
+
|
|
158
|
+
- [Node.js の用意](docs/installing-node.md) — Node.js 20 が未導入の方へ(mise / 公式インストーラ)
|
|
159
|
+
- [Windows / WSL2 での導入](docs/windows-wsl2.md) — ネイティブ Windows と WSL2 での手順
|
|
160
|
+
- [ダッシュボード](docs/dashboard.md) — 期間の連動・自動更新・履歴の削除
|
|
161
|
+
- [月予算 / Monthly budget](docs/monthly-budget.md) — 当月の使用率表示
|
|
162
|
+
- [過去分の取り込み / sweep](docs/sweep.md) — hook 導入前・後から完了した分の回収
|
|
163
|
+
- [金額の意味](docs/cost.md) — ラベルの意味と、概算値である理由
|
|
164
|
+
- [設定 / Configuration](docs/configuration.md) — `config.json` の全キーと通知の一時停止(mute)
|
|
165
|
+
- [Slack 通知の有効化](docs/slack.md) — Incoming Webhook の設定
|
|
166
|
+
- [仕組み / How it Works](docs/how-it-works.md) — Stop hook から通知までの流れ
|
|
167
|
+
- [よくある質問 / FAQ](docs/faq.md) — 通知が来ない・`/cost` との差など
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
formatJPY,
|
|
4
|
+
formatUSD
|
|
5
|
+
} from "./chunk-TBFKGFZX.js";
|
|
6
|
+
import {
|
|
7
|
+
currentMonthTotals,
|
|
8
|
+
paths,
|
|
9
|
+
readConfig
|
|
10
|
+
} from "./chunk-IIYMGLV4.js";
|
|
11
|
+
|
|
12
|
+
// src/budget.ts
|
|
13
|
+
import { writeFileSync } from "fs";
|
|
14
|
+
function parseBudgetAmount(s) {
|
|
15
|
+
const cleaned = s.replace(/[$,\s]/g, "");
|
|
16
|
+
if (cleaned === "") return null;
|
|
17
|
+
const n = Number(cleaned);
|
|
18
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
function showBudget() {
|
|
22
|
+
const cfg = readConfig();
|
|
23
|
+
const budget = cfg.monthlyBudgetUSD;
|
|
24
|
+
const { usd, jpy } = currentMonthTotals();
|
|
25
|
+
if (!(budget > 0)) {
|
|
26
|
+
console.log("\u6708\u4E88\u7B97 / Monthly budget: \u672A\u8A2D\u5B9A / not set");
|
|
27
|
+
console.log(`\u4ECA\u6708\u306E\u4F7F\u7528 / This month: ${formatUSD(usd)}(${formatJPY(jpy)})`);
|
|
28
|
+
console.log("\u8A2D\u5B9A\u3059\u308B\u306B\u306F / to set: ccc-notifier budget <USD>(\u4F8B: ccc-notifier budget 400)");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const pct = usd / budget * 100;
|
|
32
|
+
const budgetJpy = budget * cfg.fx.fallbackRate;
|
|
33
|
+
console.log(`\u6708\u4E88\u7B97 / Monthly budget: ${formatUSD(budget)}(${formatJPY(budgetJpy)})`);
|
|
34
|
+
console.log(
|
|
35
|
+
`\u4ECA\u6708\u306E\u4F7F\u7528 / This month: ${formatUSD(usd)} / ${formatUSD(budget)}(${pct.toFixed(1)}% used)`
|
|
36
|
+
);
|
|
37
|
+
console.log(` \xA5\u63DB\u7B97 / in JPY: ${formatJPY(jpy)} / ${formatJPY(budgetJpy)}`);
|
|
38
|
+
console.log("\u89E3\u9664\u3059\u308B\u306B\u306F / to clear: ccc-notifier budget 0");
|
|
39
|
+
}
|
|
40
|
+
function runBudget(argv) {
|
|
41
|
+
const args = argv.filter((a) => a !== "--yes" && a !== "-y");
|
|
42
|
+
const arg = args[0];
|
|
43
|
+
if (arg === void 0 || arg === "show" || arg === "--show") {
|
|
44
|
+
showBudget();
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const amount = arg === "off" || arg === "none" || arg === "clear" || arg === "--unset" ? 0 : parseBudgetAmount(arg);
|
|
48
|
+
if (amount === null) {
|
|
49
|
+
console.error(
|
|
50
|
+
`\u91D1\u984D\u306F 0 \u4EE5\u4E0A\u306E\u6570\u5024\u3067\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044(\u53D7\u9818: ${arg})\u3002\u4F8B: ccc-notifier budget 400 / \u89E3\u9664: ccc-notifier budget 0`
|
|
51
|
+
);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const cfg = readConfig();
|
|
55
|
+
cfg.monthlyBudgetUSD = amount;
|
|
56
|
+
writeFileSync(paths().configFile, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
57
|
+
if (amount === 0) {
|
|
58
|
+
console.log("\u6708\u4E88\u7B97\u3092\u89E3\u9664\u3057\u307E\u3057\u305F(\u672A\u8A2D\u5B9A\u306B\u623B\u3057\u307E\u3057\u305F)\u3002");
|
|
59
|
+
} else {
|
|
60
|
+
const { usd } = currentMonthTotals();
|
|
61
|
+
const pct = usd / amount * 100;
|
|
62
|
+
console.log(
|
|
63
|
+
`\u6708\u4E88\u7B97\u3092 ${formatUSD(amount)} \u306B\u8A2D\u5B9A\u3057\u307E\u3057\u305F\u3002\u4ECA\u6708\u306E\u4F7F\u7528: ${formatUSD(usd)}(${pct.toFixed(1)}% used)`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
export {
|
|
69
|
+
parseBudgetAmount,
|
|
70
|
+
runBudget
|
|
71
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
isWSL
|
|
4
|
+
} from "./chunk-DGXUSPS4.js";
|
|
5
|
+
import {
|
|
6
|
+
formatSummary
|
|
7
|
+
} from "./chunk-TBFKGFZX.js";
|
|
8
|
+
|
|
9
|
+
// src/notify/os.ts
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
|
|
12
|
+
// src/notify/util.ts
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
function cccnHome() {
|
|
17
|
+
const home = process.env.CCCN_HOME || join(homedir(), ".ccc-notifier");
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(home, { recursive: true });
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
return home;
|
|
23
|
+
}
|
|
24
|
+
function appendNotifyError(context, err) {
|
|
25
|
+
try {
|
|
26
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
27
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${context}] ${message}
|
|
28
|
+
`;
|
|
29
|
+
appendFileSync(join(cccnHome(), "error.log"), line, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function writeDryRun(channel, payload) {
|
|
34
|
+
try {
|
|
35
|
+
const file = join(cccnHome(), "last-notify.json");
|
|
36
|
+
let existing = {};
|
|
37
|
+
if (existsSync(file)) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(file, "utf8");
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (parsed && typeof parsed === "object") {
|
|
42
|
+
existing = parsed;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
existing = {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const payloadRecord = payload && typeof payload === "object" ? payload : { value: payload };
|
|
49
|
+
const next = {
|
|
50
|
+
...existing,
|
|
51
|
+
[channel]: { ...payloadRecord, ts: (/* @__PURE__ */ new Date()).toISOString() }
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(file, JSON.stringify(next, null, 2), "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/notify/os.ts
|
|
59
|
+
var NOTIFY_TIMEOUT_MS = 3e3;
|
|
60
|
+
function spawnDarwinNotify(title, body) {
|
|
61
|
+
return spawn(
|
|
62
|
+
"osascript",
|
|
63
|
+
[
|
|
64
|
+
"-e",
|
|
65
|
+
"on run argv",
|
|
66
|
+
"-e",
|
|
67
|
+
"display notification (item 2 of argv) with title (item 1 of argv)",
|
|
68
|
+
"-e",
|
|
69
|
+
"end run",
|
|
70
|
+
title,
|
|
71
|
+
body
|
|
72
|
+
],
|
|
73
|
+
{ stdio: "ignore" }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
var WIN_AUMID = "ccc-notifier.notify";
|
|
77
|
+
var WIN_FALLBACK_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
78
|
+
var WIN_SHORTCUT_CSHARP = `
|
|
79
|
+
using System;
|
|
80
|
+
using System.Runtime.InteropServices;
|
|
81
|
+
public static class CccShortcut {
|
|
82
|
+
public static void Create(string shortcutPath, string aumid) {
|
|
83
|
+
var link = (IShellLinkW)new CShellLink();
|
|
84
|
+
string ps = System.IO.Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
85
|
+
link.SetPath(ps);
|
|
86
|
+
var store = (IPropertyStore)link;
|
|
87
|
+
PROPVARIANT pv; InitPropVariantFromString(aumid, out pv);
|
|
88
|
+
var key = new PROPERTYKEY(); key.fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"); key.pid = 5;
|
|
89
|
+
store.SetValue(ref key, ref pv); store.Commit();
|
|
90
|
+
((IPersistFile)link).Save(shortcutPath, true);
|
|
91
|
+
PropVariantClear(ref pv);
|
|
92
|
+
}
|
|
93
|
+
[DllImport("propsys.dll")] static extern int InitPropVariantFromString([MarshalAs(UnmanagedType.LPWStr)] string psz, out PROPVARIANT ppropvar);
|
|
94
|
+
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PROPVARIANT pvar);
|
|
95
|
+
}
|
|
96
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
|
|
97
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
|
|
98
|
+
interface IShellLinkW {
|
|
99
|
+
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder f, int c, IntPtr p, int fl);
|
|
100
|
+
void GetIDList(out IntPtr ppidl); void SetIDList(IntPtr pidl);
|
|
101
|
+
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder n, int c);
|
|
102
|
+
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
103
|
+
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder d, int c);
|
|
104
|
+
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string d);
|
|
105
|
+
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder a, int c);
|
|
106
|
+
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string a);
|
|
107
|
+
void GetHotkey(out short w); void SetHotkey(short w);
|
|
108
|
+
void GetShowCmd(out int s); void SetShowCmd(int s);
|
|
109
|
+
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder i, int c, out int idx);
|
|
110
|
+
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string i, int idx);
|
|
111
|
+
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string r, int d);
|
|
112
|
+
void Resolve(IntPtr h, int f); void SetPath([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
113
|
+
}
|
|
114
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("0000010b-0000-0000-C000-000000000046")]
|
|
115
|
+
interface IPersistFile {
|
|
116
|
+
void GetClassID(out Guid c); [PreserveSig] int IsDirty();
|
|
117
|
+
void Load([MarshalAs(UnmanagedType.LPWStr)] string f, int m);
|
|
118
|
+
void Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool r);
|
|
119
|
+
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
120
|
+
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
|
|
121
|
+
}
|
|
122
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99")]
|
|
123
|
+
interface IPropertyStore {
|
|
124
|
+
void GetCount(out uint c); void GetAt(uint i, out PROPERTYKEY k);
|
|
125
|
+
void GetValue(ref PROPERTYKEY k, out PROPVARIANT pv);
|
|
126
|
+
void SetValue(ref PROPERTYKEY k, ref PROPVARIANT pv); void Commit();
|
|
127
|
+
}
|
|
128
|
+
[StructLayout(LayoutKind.Sequential)] struct PROPERTYKEY { public Guid fmtid; public uint pid; }
|
|
129
|
+
[StructLayout(LayoutKind.Sequential)] struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; }
|
|
130
|
+
`;
|
|
131
|
+
function spawnWin32Notify(title, body) {
|
|
132
|
+
const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
|
|
133
|
+
const csB64 = Buffer.from(WIN_SHORTCUT_CSHARP, "utf8").toString("base64");
|
|
134
|
+
const psScript = [
|
|
135
|
+
"$ErrorActionPreference='Stop'",
|
|
136
|
+
`$psAumid='${WIN_FALLBACK_AUMID}'`,
|
|
137
|
+
`$aumid='${WIN_AUMID}'`,
|
|
138
|
+
// 初回のみ ccc-notifier のショートカット + AUMID を登録。失敗したら PowerShell の AUMID へ倒す。
|
|
139
|
+
"try {",
|
|
140
|
+
" $lnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'ccc-notifier.lnk'",
|
|
141
|
+
" if(-not (Test-Path -LiteralPath $lnk)){",
|
|
142
|
+
` Add-Type -TypeDefinition ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${csB64}')))`,
|
|
143
|
+
" [CccShortcut]::Create($lnk, $aumid)",
|
|
144
|
+
" }",
|
|
145
|
+
"} catch { $aumid = $psAumid }",
|
|
146
|
+
// 本文は環境変数ではなく Base64 埋め込みから復元する(WSL でも確実に渡すため)。
|
|
147
|
+
`$title=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64(title)}'))`,
|
|
148
|
+
`$body=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64(body)}'))`,
|
|
149
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]",
|
|
150
|
+
"$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
151
|
+
"$n=$t.GetElementsByTagName('text')",
|
|
152
|
+
"[void]$n.Item(0).AppendChild($t.CreateTextNode($title))",
|
|
153
|
+
"[void]$n.Item(1).AppendChild($t.CreateTextNode($body))",
|
|
154
|
+
"$toast=[Windows.UI.Notifications.ToastNotification]::new($t)",
|
|
155
|
+
"try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast) }",
|
|
156
|
+
"catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($psAumid).Show($toast) }"
|
|
157
|
+
].join("\n");
|
|
158
|
+
const encoded = Buffer.from(psScript, "utf16le").toString("base64");
|
|
159
|
+
return spawn(
|
|
160
|
+
"powershell.exe",
|
|
161
|
+
["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", encoded],
|
|
162
|
+
{ stdio: "ignore" }
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
function spawnLinuxNotify(title, body) {
|
|
166
|
+
return spawn("notify-send", [title, body], { stdio: "ignore" });
|
|
167
|
+
}
|
|
168
|
+
function selectNotifyBackend() {
|
|
169
|
+
if (process.platform === "darwin") return { kind: "darwin", swallowError: false };
|
|
170
|
+
if (process.platform === "win32") return { kind: "win32", swallowError: false };
|
|
171
|
+
if (isWSL()) return { kind: "win32", swallowError: false };
|
|
172
|
+
return { kind: "linux", swallowError: true };
|
|
173
|
+
}
|
|
174
|
+
function spawnNotifyChild(title, body) {
|
|
175
|
+
const backend = selectNotifyBackend();
|
|
176
|
+
if (backend.kind === "darwin") {
|
|
177
|
+
return { child: spawnDarwinNotify(title, body), swallowError: backend.swallowError };
|
|
178
|
+
}
|
|
179
|
+
if (backend.kind === "win32") {
|
|
180
|
+
return { child: spawnWin32Notify(title, body), swallowError: backend.swallowError };
|
|
181
|
+
}
|
|
182
|
+
return { child: spawnLinuxNotify(title, body), swallowError: backend.swallowError };
|
|
183
|
+
}
|
|
184
|
+
async function notifyOS(record, cfg, todayUSD) {
|
|
185
|
+
try {
|
|
186
|
+
if (!cfg?.notify?.os) return;
|
|
187
|
+
const { title, body } = formatSummary(record, cfg, todayUSD);
|
|
188
|
+
if (process.env.CCCN_DRY_RUN === "1") {
|
|
189
|
+
writeDryRun("os", { title, body });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const { child, swallowError } = spawnNotifyChild(title, body);
|
|
193
|
+
let timer;
|
|
194
|
+
try {
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
child.on("error", (err) => {
|
|
197
|
+
if (!swallowError) appendNotifyError("notifyOS", err);
|
|
198
|
+
resolve();
|
|
199
|
+
});
|
|
200
|
+
child.on("exit", (code) => {
|
|
201
|
+
if (code !== 0 && code !== null) {
|
|
202
|
+
appendNotifyError("notifyOS", new Error(`exit code ${code}`));
|
|
203
|
+
}
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
timer = setTimeout(() => {
|
|
207
|
+
try {
|
|
208
|
+
child.kill();
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
resolve();
|
|
212
|
+
}, NOTIFY_TIMEOUT_MS);
|
|
213
|
+
});
|
|
214
|
+
} finally {
|
|
215
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
appendNotifyError("notifyOS", err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/notify/slack.ts
|
|
223
|
+
var SEND_TIMEOUT_MS = 3e3;
|
|
224
|
+
async function notifySlack(record, cfg, todayUSD) {
|
|
225
|
+
try {
|
|
226
|
+
const slackCfg = cfg?.notify?.slack;
|
|
227
|
+
const webhookUrl = slackCfg?.webhookUrl;
|
|
228
|
+
if (!webhookUrl) return;
|
|
229
|
+
const { title, body } = formatSummary(record, cfg, todayUSD);
|
|
230
|
+
const line1 = body.split("\n")[0] ?? "";
|
|
231
|
+
const rawPrompt = record.prompt ?? "";
|
|
232
|
+
let promptText;
|
|
233
|
+
if (rawPrompt.length === 0) {
|
|
234
|
+
promptText = "(\u30D7\u30ED\u30F3\u30D7\u30C8\u306A\u3057)";
|
|
235
|
+
} else if (slackCfg.sendFullPrompt) {
|
|
236
|
+
promptText = rawPrompt;
|
|
237
|
+
} else {
|
|
238
|
+
const limit = slackCfg.promptChars;
|
|
239
|
+
promptText = rawPrompt.length > limit ? rawPrompt.slice(0, limit) : rawPrompt;
|
|
240
|
+
}
|
|
241
|
+
const payload = {
|
|
242
|
+
blocks: [
|
|
243
|
+
{ type: "header", text: { type: "plain_text", text: title } },
|
|
244
|
+
{ type: "section", text: { type: "mrkdwn", text: line1 } },
|
|
245
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: promptText }] }
|
|
246
|
+
]
|
|
247
|
+
};
|
|
248
|
+
if (process.env.CCCN_DRY_RUN === "1") {
|
|
249
|
+
writeDryRun("slack", { payload });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(webhookUrl, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: { "content-type": "application/json" },
|
|
258
|
+
body: JSON.stringify(payload),
|
|
259
|
+
signal: controller.signal
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
appendNotifyError("notifySlack", new Error(`Slack webhook responded with status ${res.status}`));
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
appendNotifyError("notifySlack", err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
selectNotifyBackend,
|
|
274
|
+
notifyOS,
|
|
275
|
+
notifySlack
|
|
276
|
+
};
|