diff --git a/CHANGELOG.md b/CHANGELOG.md index 204fc93..c4925d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to Token Ghost are documented here. Versions follow [Semantic Versioning](https://semver.org). +## [Unreleased] + +### Gemini +- Show Pro / Flash / Flash Lite daily quotas from Code Assist `retrieveUserQuota` using Gemini CLI Google-login credentials. +- Prefer macOS Keychain (`gemini-cli-oauth` / `main-account`) over legacy `oauth_creds.json`. +- Default: refresh OAuth access tokens **in memory only** (no Keychain mutation). Opt in with `TOKEN_GHOST_GEMINI_WRITEBACK=1`. +- Resolve OAuth client id/secret automatically from the installed `@google/gemini-cli` package (or env / creds fields) โ€” no hard-coded client values. +- On refresh failure, do not reuse a stale/short access token; surface a re-login hint instead. +- Surface a clear setup hint when only API key / Vertex auth is configured. +- Add bun's `~/.bun/bin` to the PATH bootstrap so a bun-installed `gemini` is found under SwiftBar. + +### Privacy / hardening +- Document Keychain vs file credential handling and write-back opt-in accurately; cache file mode `600` when possible. +- HTTP errors expose status codes only (no upstream bodies in menus / `--collect`). + +### Docs +- Document Gemini data source, Google-login requirement, Keychain, env overrides, unofficial Claude TUI / Code Assist API limits, `v1.0.0` install pin vs Gemini branch, and Open Gemini menu action. + +### Cleanup +- Remove unused severity helpers. +- Ignore `_workspace/` artifacts. + ## [1.0.0] - 2026-07-10 First stable release. diff --git a/README.ko.md b/README.ko.md index e43bdeb..e941790 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,8 +1,8 @@ # Token Ghost ๐Ÿ‘ป -**Claude Code**์™€ **Codex**์˜ ์‚ฌ์šฉ ํ•œ๋„๋ฅผ macOS ๋ฉ”๋‰ด๋ฐ”์—์„œ ํ•œ๋ˆˆ์— ๋ณด์—ฌ์ฃผ๋Š” ์ž‘์€ ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. +**Claude Code**, **Codex**, **Gemini CLI**์˜ ์‚ฌ์šฉ ํ•œ๋„๋ฅผ macOS ๋ฉ”๋‰ด๋ฐ”์—์„œ ํ•œ๋ˆˆ์— ๋ณด์—ฌ์ฃผ๋Š” ์ž‘์€ ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. -Token Ghost๋Š” [SwiftBar](https://swiftbar.app) ์œ„์—์„œ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. SwiftBar๊ฐ€ ๋ฉ”๋‰ด๋ฐ” ์•ฑ์ด๊ณ , Token Ghost๋Š” ๊ทธ ์•ˆ์—์„œ Claude/Codex ์‚ฌ์šฉ๋Ÿ‰์„ ์ฝ์–ด ํ‘œ์‹œํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. +Token Ghost๋Š” [SwiftBar](https://swiftbar.app) ์œ„์—์„œ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. SwiftBar๊ฐ€ ๋ฉ”๋‰ด๋ฐ” ์•ฑ์ด๊ณ , Token Ghost๋Š” ๊ทธ ์•ˆ์—์„œ Claude/Codex/Gemini ์‚ฌ์šฉ๋Ÿ‰์„ ์ฝ์–ด ํ‘œ์‹œํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. > ๐Ÿ‡บ๐Ÿ‡ธ English guide: [README.md](README.md) @@ -24,6 +24,11 @@ Checked 3m ago 5 Hour: ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 12% (~8:28pm) Weekly: ๐ŸŸฉ๐ŸŸฉ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 35% (~Jul 17 6am) From session log, 40m ago + +๐Ÿ’œ Gemini +Pro: ๐ŸŸฉ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 28% (~3:00am) +Flash: ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 12% (~3:00am) +From Code Assist, just now ``` ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”๋Š” ์‚ฌ์šฉ๋ฅ ์ด ์˜ค๋ฅผ์ˆ˜๋ก ์ดˆ๋ก โ†’ ๋…ธ๋ž‘ โ†’ ์ฃผํ™ฉ โ†’ ๋นจ๊ฐ•์œผ๋กœ ์ฑ„์›Œ์ง‘๋‹ˆ๋‹ค. ๊ฐ ํ•ญ๋ชฉ์€ ๋ฆฌ์…‹ ์‹œ๊ฐ์„ `(~์‹œ๊ฐ)`์œผ๋กœ ๋ณด์—ฌ์ฃผ๊ณ , ๋„๊ตฌ๋ณ„๋กœ ๊ฐ’์ด ์–ผ๋งˆ๋‚˜ ์ตœ์‹ ์ธ์ง€๋„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. @@ -36,11 +41,11 @@ Token Ghost๋Š” **๋…๋ฆฝ ์•ฑ์ด ์•„๋‹ˆ๋ผ** SwiftBar ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค: ```text SwiftBar = ๋ฉ”๋‰ด๋ฐ”์— ๊ธ€์ž๋ฅผ ๋„์›Œ์ฃผ๋Š” macOS ์•ฑ -Token Ghost = Claude/Codex ์‚ฌ์šฉ๋Ÿ‰์„ ์ฝ์–ด ๋ฉ”๋‰ด์— ํ‘œ์‹œํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ -Claude / Codex CLI = ์‚ฌ์šฉ ํ•œ๋„๋ฅผ ์‹ค์ œ๋กœ ์•Œ๊ณ  ์žˆ๋Š” ๋„๊ตฌ +Token Ghost = Claude/Codex/Gemini ์‚ฌ์šฉ๋Ÿ‰์„ ์ฝ์–ด ๋ฉ”๋‰ด์— ํ‘œ์‹œํ•˜๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ +Claude / Codex / Gemini CLI = ์‚ฌ์šฉ ํ•œ๋„๋ฅผ ์‹ค์ œ๋กœ ์•Œ๊ณ  ์žˆ๋Š” ๋„๊ตฌ ``` -์ฆ‰, ํ„ฐ๋ฏธ๋„์—์„œ Claude Code์™€ Codex๊ฐ€ ์ด๋ฏธ ๋™์ž‘ํ•˜๋Š” ์ƒํƒœ์—ฌ์•ผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +์ฆ‰, ํ„ฐ๋ฏธ๋„์—์„œ ๊ฐ CLI๊ฐ€ ์ด๋ฏธ ๋™์ž‘ํ•˜๋Š” ์ƒํƒœ์—ฌ์•ผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ### ์ˆซ์ž๋Š” ์–ด๋””์„œ ์˜ค๋‚˜์š” @@ -51,6 +56,8 @@ Claude / Codex CLI = ์‚ฌ์šฉ ํ•œ๋„๋ฅผ ์‹ค์ œ๋กœ ์•Œ๊ณ  ์žˆ๋Š” ๋„๊ตฌ - ๋ฉ”๋‰ด์— ๋ฐ์ดํ„ฐ ๋‚˜์ด(`From session log, 40m ago`)๋ฅผ ํ‘œ์‹œํ•˜๊ณ , - ๋ฆฌ์…‹ ์‹œ๊ฐ์ด ์ง€๋‚œ ์œˆ๋„์šฐ๋Š” `0%`์™€ ํ•จ๊ป˜ **๋‹ค์Œ ๋ฆฌ์…‹ ์˜ˆ์ƒ ์‹œ๊ฐ**(๋ณด๊ณ ๋œ ๋ฆฌ์…‹ ์‹œ๊ฐ์— ์ฐฝ ๊ธธ์ด๋ฅผ ๋”ํ•ด ๊ณ„์‚ฐ)์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ: `5 Hour: 0% (~3:18am)`. ๋‹ค์Œ Codex ์‹คํ–‰์ด ์ƒˆ ๊ฐ’์„ ๊ธฐ๋กํ•˜๋ฉด ์‹ค์ œ ๊ฐ’์œผ๋กœ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. +**Gemini** โ€” Gemini CLI๋ฅผ **Google ๊ณ„์ •์œผ๋กœ ๋กœ๊ทธ์ธ**ํ–ˆ์„ ๋•Œ ์ƒ๊ธฐ๋Š” OAuth๋กœ Code Assist `retrieveUserQuota`๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. ์ตœ์‹  Gemini CLI๋Š” macOS Keychain(`gemini-cli-oauth` / `main-account`)์— ๋‘๊ณ , ์˜ˆ์ „ ์„ค์น˜๋Š” `~/.gemini/oauth_creds.json`์„ ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ๋Š” Gemini CLI์™€ ๊ฐ™์ด **Pro / Flash / Flash Lite** ์ผ์ผ ๋ฒ„ํ‚ท์œผ๋กœ ํ•ฉ์ณ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. API ํ‚คยทVertex ์ธ์ฆ์€ ๋กœ์ปฌ์— ์ด ์ผ์ผ ๋ฐ”๊ฐ€ ์—†์–ด, `gemini`์—์„œ `/auth` โ†’ **Login with Google**์œผ๋กœ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค. + --- ## ์ค€๋น„๋ฌผ @@ -60,12 +67,14 @@ Claude / Codex CLI = ์‚ฌ์šฉ ํ•œ๋„๋ฅผ ์‹ค์ œ๋กœ ์•Œ๊ณ  ์žˆ๋Š” ๋„๊ตฌ - [SwiftBar](https://swiftbar.app) - Claude Code CLI ๋กœ๊ทธ์ธ โ€” Claude ์‚ฌ์šฉ๋Ÿ‰ ํ‘œ์‹œ์— ํ•„์š” - Codex CLI ๋กœ๊ทธ์ธ โ€” Codex ์‚ฌ์šฉ๋Ÿ‰ ํ‘œ์‹œ์— ํ•„์š” +- Gemini CLI **Google ๋กœ๊ทธ์ธ** โ€” Gemini Pro/Flash ์ผ์ผ ์ฟผํ„ฐ ํ‘œ์‹œ์— ํ•„์š” Token Ghost๋Š” ๋Œ€์‹  ๋กœ๊ทธ์ธํ•ด์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๊ฐ CLI๊ฐ€ ์ด๋ฏธ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”: ```bash codex login status # Codex claude # Claude Code: TUI๊ฐ€ ์—ด๋ฆฌ๋ฉฐ, ํ•„์š”ํ•˜๋ฉด /login์œผ๋กœ ๋กœ๊ทธ์ธ +gemini # Gemini: /auth โ†’ Login with Google (Keychain ๋˜๋Š” oauth_creds.json) ``` --- @@ -78,6 +87,8 @@ claude # Claude Code: TUI๊ฐ€ ์—ด๋ฆฌ๋ฉฐ, ํ•„์š”ํ•˜๋ฉด /login์œผ๋กœ curl -fsSL https://raw.githubusercontent.com/zoeymakes/token-ghost/v1.0.0/install.sh | bash ``` +> **์ฐธ๊ณ :** ํ•€๋œ `v1.0.0` ์„ค์น˜๋Š” Claude + Codex๋งŒ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. Gemini ์ง€์›์€ ์ด ๋ธŒ๋žœ์น˜ / ๋‹ค์Œ ๋ฆด๋ฆฌ์Šค์— ์žˆ์Šต๋‹ˆ๋‹ค. Gemini๊ฐ€ ํ•„์š”ํ•˜๋ฉด ๋กœ์ปฌ ์ฒดํฌ์•„์›ƒ์—์„œ `./install.sh`๋ฅผ ์“ฐ๊ฑฐ๋‚˜ Gemini๊ฐ€ ํฌํ•จ๋œ ํƒœ๊ทธ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ์„ธ์š”. + ์„ค์น˜ ์Šคํฌ๋ฆฝํŠธ ๋™์ž‘: ```text @@ -104,7 +115,7 @@ SwiftBar๊ฐ€ ์ž๋™์œผ๋กœ ์—ด๋ฆฝ๋‹ˆ๋‹ค. **Plugin Folder**๋ฅผ ๋ฌผ์–ด๋ณด๋ฉด, ์„ค์น˜ - **Refresh** โ€” ์ง€๊ธˆ ์ฆ‰์‹œ ์ƒˆ๋กœ ์ˆ˜์ง‘ (Claude ์บ์‹œ TTL ๋ฌด์‹œ). - **Setup guide** โ€” ์ด README ์—ด๊ธฐ. -- **Open Claude / Open Codex** โ€” ํ•ด๋‹น ์•ฑ ๋˜๋Š” ์›น์‚ฌ์ดํŠธ ์—ด๊ธฐ. +- **Open Claude / Open Codex / Open Gemini** โ€” ํ•ด๋‹น ์•ฑ ๋˜๋Š” ์›น์‚ฌ์ดํŠธ ์—ด๊ธฐ. --- @@ -116,6 +127,10 @@ SwiftBar๊ฐ€ ์ž๋™์œผ๋กœ ์—ด๋ฆฝ๋‹ˆ๋‹ค. **Plugin Folder**๋ฅผ ๋ฌผ์–ด๋ณด๋ฉด, ์„ค์น˜ | --- | --- | --- | | `TOKEN_GHOST_CLAUDE_TTL_MIN` | `15` | Claude `/usage` ์บก์ฒ˜ ๊ฐ„๊ฒฉ(๋ถ„) | | `TOKEN_GHOST_PLUGIN_DIR` | SwiftBar ์„ค์ • ํด๋” | ํ”Œ๋Ÿฌ๊ทธ์ธ ์„ค์น˜ ์œ„์น˜ | +| `TOKEN_GHOST_GEMINI_CLIENT_ID` / `TOKEN_GHOST_GEMINI_CLIENT_SECRET` | *(์„ค์น˜๋œ Gemini CLI์—์„œ ํƒ์ƒ‰)* | ํ† ํฐ ๊ฐฑ์‹ ์—๋งŒ ์“ฐ๋Š” Gemini OAuth client ์„ ํƒ ์˜ค๋ฒ„๋ผ์ด๋“œ | +| `GEMINI_OAUTH_CLIENT_ID` / `GEMINI_OAUTH_CLIENT_SECRET` | *(๋™์ผ)* | ์œ„์™€ ๊ฐ™์€ Gemini OAuth client ์˜ค๋ฒ„๋ผ์ด๋“œ ๋ณ„์นญ | +| `TOKEN_GHOST_GEMINI_WRITEBACK` | off | `1`์ด๋ฉด ๊ฐฑ์‹ ๋œ OAuth ํ† ํฐ์„ Gemini CLI Keychain / `oauth_creds.json`์— ๋‹ค์‹œ ์”€ (๊ธฐ๋ณธ: ๋ฉ”๋ชจ๋ฆฌ๋งŒ) | +| `TOKEN_GHOST_GEMINI_KEYCHAIN_SERVICE` / `TOKEN_GHOST_GEMINI_KEYCHAIN_ACCOUNT` | `gemini-cli-oauth` / `main-account` | ์ฝ๊ธฐยท์„ ํƒ์  write-back์— ์“ฐ๋Š” Keychain id | --- @@ -131,11 +146,13 @@ SwiftBar๊ฐ€ ์ž๋™์œผ๋กœ ์—ด๋ฆฝ๋‹ˆ๋‹ค. **Plugin Folder**๋ฅผ ๋ฌผ์–ด๋ณด๋ฉด, ์„ค์น˜ ## ๊ฐœ์ธ์ •๋ณด -Token Ghost๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ, API Key, Access Token, ๊ณ„์ • ์ธ์ฆ์ •๋ณด๋ฅผ **์ €์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค**. ์ด๋ฏธ ์„ค์น˜๋œ `claude`, `codex` ๋„๊ตฌ์—์„œ ์‚ฌ์šฉ๋Ÿ‰๋งŒ ์ฝ์–ด, ์‚ฌ์šฉ๋ฅ ๊ณผ ๋ฆฌ์…‹ ์‹œ๊ฐ๋งŒ ์•„๋ž˜์— ์บ์‹œํ•ฉ๋‹ˆ๋‹ค: +Token Ghost๋Š” **์ž์ฒด ๋น„๋ฐ€๋ฒˆํ˜ธ ์ €์žฅ์†Œ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค**. ํ•˜๋Š” ์ผ์€: -```text -~/.cache/token-ghost/cache.json -``` +- ์ด๋ฏธ ์„ค์น˜๋œ `claude`, `codex`, `gemini` ๋„๊ตฌ์—์„œ ์‚ฌ์šฉ๋Ÿ‰๋งŒ ์ฝ๊ณ , +- ์‚ฌ์šฉ๋ฅ ยท๋ฆฌ์…‹ ์‹œ๊ฐ ๋“ฑ ๋น„๋ฐ€์ด ์•„๋‹Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋งŒ `~/.cache/token-ghost/cache.json`์— ์บ์‹œํ•˜๋ฉฐ(๊ฐ€๋Šฅํ•˜๋ฉด ๊ถŒํ•œ `600`), +- ๋ฉ”๋‰ด๋‚˜ ์บ์‹œ์— access tokenยทrefresh tokenยทAPI key๋ฅผ ์ถœ๋ ฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +Gemini Google ๋กœ๊ทธ์ธ์˜ ๊ฒฝ์šฐ access token์ด ๋งŒ๋ฃŒ๋˜๋ฉด `retrieveUserQuota` ํ˜ธ์ถœ์„ ์œ„ํ•ด **๋ฉ”๋ชจ๋ฆฌ์—์„œ๋งŒ** ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ Gemini CLI Keychain์ด๋‚˜ `oauth_creds.json`์—๋Š” ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค(๋‹ค๋ฅธ ์•ฑ ์ž๊ฒฉ์ฆ๋ช…์„ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ธฐ ์œ„ํ•จ). ๊ฐ™์€ ์ €์žฅ์†Œ์— write-back์ด ํ•„์š”ํ•˜๋ฉด `TOKEN_GHOST_GEMINI_WRITEBACK=1`์„ ์„ค์ •ํ•˜์„ธ์š”. --- @@ -168,7 +185,10 @@ curl -fsSL https://raw.githubusercontent.com/zoeymakes/token-ghost/main/uninstal - ๋…๋ฆฝ `.app`์ด ์•„๋‹ˆ๋ผ SwiftBar ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. - Codex ๊ฐ’์€ Codex๋ฅผ ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•  ๋•Œ๋งŒ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. ๋Œ€์‹  ๋ฐ์ดํ„ฐ ๋‚˜์ด๋ฅผ ํ‘œ์‹œํ•˜๊ณ , ๋ฆฌ์…‹์ด ์ง€๋‚œ ์œˆ๋„์šฐ๋Š” ์˜ˆ์ƒ ๋ฆฌ์…‹ ์‹œ๊ฐ์˜ ์ถ”์ •๊ฐ’์œผ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. -- Claude ๊ฐ’์€ ์งง์€ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์บก์ฒ˜๋กœ ์–ป์œผ๋ฉฐ, ์บก์ฒ˜ ์‚ฌ์ด(15๋ถ„ TTL)์—๋Š” ์บ์‹œ๋œ ๊ฐ’๊ณผ ๋‚˜์ด๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. +- Claude ๊ฐ’์€ ์งง์€ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ `/usage` ์บก์ฒ˜(Claude Code TUI ๋น„๊ณต์‹ ์ž๋™ํ™”)๋กœ ์–ป์Šต๋‹ˆ๋‹ค. ์ฒซ ์‹คํ–‰ ์‹œ workspace trust ํ”„๋กฌํ”„ํŠธ๋ฅผ Enter๋กœ ์ž๋™ ์ˆ˜๋ฝํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์บก์ฒ˜ ์‚ฌ์ด(15๋ถ„ TTL)์—๋Š” ์บ์‹œ๋œ ๊ฐ’๊ณผ ๋‚˜์ด๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. +- Gemini Pro/Flash ๋ฐ”๋Š” Gemini CLI **Google ๊ณ„์ • OAuth**(Keychain ๋˜๋Š” `oauth_creds.json`)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. API ํ‚คยทVertex ์ธ์ฆ์œผ๋กœ๋Š” ์ผ์ผ ๋ฒ„ํ‚ท์„ ํ‘œ์‹œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +- Gemini ์ฟผํ„ฐ๋Š” Google Code Assist `v1internal:retrieveUserQuota`(Gemini CLI๊ฐ€ ์“ฐ๋Š” ๋น„๊ณต์‹์— ๊ฐ€๊นŒ์šด API)๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ์˜ˆ๊ณ  ์—†์ด ๋ฐ”๋€Œ๊ฑฐ๋‚˜ ์ผ๋ถ€ ๊ฐœ์ธ ๊ณ„์ • ๋“ฑ๊ธ‰์„ ๊ฑฐ๋ถ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์ผ๋ถ€ ๊ฐœ์ธ Google ๊ณ„์ • ๋“ฑ๊ธ‰์€ Gemini CLI OAuth๋กœ Code Assist ์ฟผํ„ฐ๋ฅผ ๋” ์ด์ƒ ๋ฐ›์ง€ ๋ชปํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Google์ด ๊ฑฐ๋ถ€ํ•˜๋ฉด ๋ฉ”๋‰ด์— ์„ค์ • ์•ˆ๋‚ด๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. - SwiftBar ๋ฉ”๋‰ด๋Š” ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜์ด๋ผ ํ”„๋กœ๊ทธ๋ ˆ์Šค๋ฐ”๋Š” ์ปฌ๋Ÿฌ ์‚ฌ๊ฐํ˜•์œผ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. --- diff --git a/README.md b/README.md index b0d14f6..5a0ce29 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Token Ghost ๐Ÿ‘ป -A small macOS menu bar plugin that shows your **Claude Code** and **Codex** usage limits at a glance. +A small macOS menu bar plugin that shows your **Claude Code**, **Codex**, and **Gemini CLI** usage limits at a glance. -Token Ghost runs inside [SwiftBar](https://swiftbar.app). SwiftBar is the menu bar app; Token Ghost is the plugin that reads your Claude/Codex usage and renders it. +Token Ghost runs inside [SwiftBar](https://swiftbar.app). SwiftBar is the menu bar app; Token Ghost is the plugin that reads your Claude/Codex/Gemini usage and renders it. > ๐Ÿ‡ฐ๐Ÿ‡ท ํ•œ๊ตญ์–ด ์•ˆ๋‚ด๋Š” [README.ko.md](README.ko.md)๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”. @@ -24,6 +24,11 @@ Checked 3m ago 5 Hour: ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 12% (~8:28pm) Weekly: ๐ŸŸฉ๐ŸŸฉ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 35% (~Jul 17 6am) From session log, 40m ago + +๐Ÿ’œ Gemini +Pro: ๐ŸŸฉ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 28% (~3:00am) +Flash: ๐ŸŸฉโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธโฌœ๏ธ 12% (~3:00am) +From Code Assist, just now ``` The bar fills green โ†’ yellow โ†’ orange โ†’ red as usage climbs. Every bucket shows its reset time as `(~time)`, and each tool shows how fresh its numbers are. @@ -36,11 +41,11 @@ Token Ghost is **not a standalone app**. It is a plugin for SwiftBar: ```text SwiftBar = the macOS menu bar app that displays text -Token Ghost = the plugin that reads Claude/Codex usage and prints the menu -Claude / Codex CLI = the tools that actually know your usage limits +Token Ghost = the plugin that reads Claude/Codex/Gemini usage and prints the menu +Claude / Codex / Gemini CLI = the tools that actually know your usage limits ``` -So it only works once Claude Code and Codex already work in your terminal. +So it only works once those CLIs already work in your terminal. ### Where the numbers come from @@ -51,6 +56,8 @@ So it only works once Claude Code and Codex already work in your terminal. - the menu shows the data age (`From session log, 40m ago`), and - once a window's reset time has passed, the bucket shows `0%` with the **projected next reset** (the reported reset rolled forward by the window length), e.g. `5 Hour: 0% (~3:18am)`, until the next Codex run writes fresh numbers. +**Gemini** โ€” read from Google Code Assist `retrieveUserQuota` using Gemini CLI Google-login credentials. On current Gemini CLI builds those live in the macOS Keychain (`gemini-cli-oauth` / `main-account`); older installs may still use `~/.gemini/oauth_creds.json`. Buckets are collapsed into **Pro / Flash / Flash Lite** (same grouping as Gemini CLI's model usage UI). API key and Vertex auth do **not** expose those local daily bars โ€” switch to **Login with Google** via `/auth` inside `gemini`. + --- ## Requirements @@ -60,12 +67,14 @@ So it only works once Claude Code and Codex already work in your terminal. - [SwiftBar](https://swiftbar.app) - Claude Code CLI, logged in โ€” required for Claude usage - Codex CLI, logged in โ€” required for Codex usage +- Gemini CLI, logged in with **Google** โ€” required for Gemini Pro/Flash daily quotas Token Ghost never logs in for you. Check that each CLI already works: ```bash codex login status # Codex claude # Claude Code: opens the TUI; log in with /login if asked +gemini # Gemini: /auth โ†’ Login with Google (Keychain or oauth_creds.json) ``` --- @@ -78,6 +87,8 @@ Paste this into Terminal (no angle brackets): curl -fsSL https://raw.githubusercontent.com/zoeymakes/token-ghost/v1.0.0/install.sh | bash ``` +> **Note:** The pinned `v1.0.0` install is Claude + Codex only. Gemini support is in this branch / the next release โ€” install from a local checkout (`./install.sh`) or wait for a tagged release that includes Gemini. + What the installer does: ```text @@ -104,7 +115,7 @@ SwiftBar opens automatically. If it asks for a **Plugin Folder**, the installer - **Refresh** โ€” force a fresh capture now (bypasses the Claude cache TTL). - **Setup guide** โ€” open this README. -- **Open Claude / Open Codex** โ€” launch the respective app or website. +- **Open Claude / Open Codex / Open Gemini** โ€” launch the respective app or website. --- @@ -116,6 +127,10 @@ Environment variables (set them where SwiftBar can see them, or edit the plugin) | --- | --- | --- | | `TOKEN_GHOST_CLAUDE_TTL_MIN` | `15` | Minutes between Claude `/usage` captures | | `TOKEN_GHOST_PLUGIN_DIR` | SwiftBar's configured folder | Where the plugin is installed | +| `TOKEN_GHOST_GEMINI_CLIENT_ID` / `TOKEN_GHOST_GEMINI_CLIENT_SECRET` | *(from installed Gemini CLI)* | Optional override for Gemini OAuth client used only when refreshing tokens | +| `GEMINI_OAUTH_CLIENT_ID` / `GEMINI_OAUTH_CLIENT_SECRET` | *(same)* | Alternate names for the same Gemini OAuth client override | +| `TOKEN_GHOST_GEMINI_WRITEBACK` | off | Set to `1` to write refreshed OAuth tokens back into Gemini CLI Keychain / `oauth_creds.json` (default: in-memory only) | +| `TOKEN_GHOST_GEMINI_KEYCHAIN_SERVICE` / `TOKEN_GHOST_GEMINI_KEYCHAIN_ACCOUNT` | `gemini-cli-oauth` / `main-account` | Keychain ids used for both read and optional write-back | --- @@ -131,11 +146,13 @@ Environment variables (set them where SwiftBar can see them, or edit the plugin) ## Privacy -Token Ghost does **not** store passwords, API keys, access tokens, or account credentials. It reads usage from the already-installed `claude` and `codex` tools and caches only usage percentages and reset times at: +Token Ghost does **not** invent its own password store. It: -```text -~/.cache/token-ghost/cache.json -``` +- reads usage from the already-installed `claude`, `codex`, and `gemini` tooling, +- caches only usage percentages, reset times, and similar non-secret metadata at `~/.cache/token-ghost/cache.json` (mode `600` when the filesystem allows), +- never prints access tokens, refresh tokens, or API keys in the menu or cache. + +For Gemini Google login Token Ghost may **refresh** an expired OAuth access token in memory to call `retrieveUserQuota`. By default it does **not** write those refreshed tokens back into Gemini CLI's Keychain or `oauth_creds.json` (avoids mutating another app's credentials). Set `TOKEN_GHOST_GEMINI_WRITEBACK=1` if you want write-back to the same store the credentials were loaded from. --- @@ -168,7 +185,10 @@ To remove the local cache too, from a downloaded copy: - Token Ghost is a SwiftBar plugin, not a standalone `.app`. - Codex numbers move only when Codex actually runs; the menu shows the data age and marks passed reset windows as projected estimates. -- Claude usage comes from a short interactive capture; between captures (15 min TTL) the menu shows the cached value with its age. +- Claude usage comes from a short interactive `/usage` capture (unofficial automation of the Claude Code TUI). On first launch that capture may auto-accept Claude's workspace-trust prompt with Enter; between captures (15 min TTL) the menu shows the cached value with its age. +- Gemini Pro/Flash bars require Gemini CLI **Google account** OAuth (Keychain or `oauth_creds.json`). API key / Vertex auth cannot show those daily buckets. +- Gemini quota is fetched via Google Code Assist's `v1internal:retrieveUserQuota` endpoint โ€” the same private-ish API Gemini CLI uses. It can change or reject some personal account tiers without notice. +- Some personal Google account tiers may no longer receive Code Assist quota through Gemini CLI OAuth; Token Ghost surfaces that as a setup error when Google returns it. - SwiftBar menus are text, so progress bars are drawn with colored squares. --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e0cf1a2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## What Token Ghost touches + +Token Ghost is a local SwiftBar plugin. It never sends your prompts or project files to a Token Ghost server (there isn't one). + +| Surface | Behavior | +| --- | --- | +| Claude | Spawns a short local `claude` TUI session to capture `/usage`. May auto-accept the first-run workspace trust dialog with Enter. | +| Codex | Reads local session logs under `~/.codex/sessions/`. | +| Gemini | Reads Google OAuth tokens that Gemini CLI already stored (Keychain `gemini-cli-oauth` or `~/.gemini/oauth_creds.json`), may refresh the access token **in memory** to call Google Code Assist `retrieveUserQuota`. By default it does **not** write refreshed tokens back into Gemini CLI stores (`TOKEN_GHOST_GEMINI_WRITEBACK=1` opts in). | +| Cache | Writes usage percentages / reset times to `~/.cache/token-ghost/cache.json` (mode `600` when possible). | + +OAuth client id/secret used for Gemini token refresh are discovered from the installed `@google/gemini-cli` package or optional environment overrides โ€” they are not hard-coded in this repository. + +## Reporting a vulnerability + +Please open a private GitHub security advisory on the repository you cloned from, or email the maintainer listed on that repository. Include steps to reproduce and whether credentials or tokens can be exposed. + +Do not file public issues that include access tokens, refresh tokens, API keys, or keychain dumps. diff --git a/token_ghost.py b/token_ghost.py index 1e1dac7..1a06dab 100755 --- a/token_ghost.py +++ b/token_ghost.py @@ -9,8 +9,16 @@ (~/.codex/sessions/**/*.jsonl). These update only when Codex actually runs, so the UI shows the data age and marks a bucket as "reset (est.)" once its reset time has passed. - -No credentials are printed. Cache lives at ~/.cache/token-ghost/cache.json. +- Gemini CLI: Google Code Assist `retrieveUserQuota` via Gemini CLI Google + OAuth (macOS Keychain `gemini-cli-oauth` / `main-account`, or legacy + ~/.gemini/oauth_creds.json). Pro / Flash / Flash Lite daily buckets. + API-key and Vertex auth do not expose those bars. + +No credentials are printed in the menu. Usage cache lives at +~/.cache/token-ghost/cache.json. By default Gemini token refresh stays +in-memory (does not mutate Gemini CLI Keychain). Set +TOKEN_GHOST_GEMINI_WRITEBACK=1 to write refreshed tokens back to the store +they were loaded from (Keychain or oauth_creds.json). """ from __future__ import annotations @@ -22,15 +30,36 @@ import shutil import signal import struct +import subprocess import sys import termios import time +import urllib.error +import urllib.parse +import urllib.request from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path import fcntl +import mmap CACHE_PATH = Path.home() / ".cache" / "token-ghost" / "cache.json" +GEMINI_DIR = Path.home() / ".gemini" +GEMINI_OAUTH_CREDS = GEMINI_DIR / "oauth_creds.json" +GEMINI_SETTINGS = GEMINI_DIR / "settings.json" +GEMINI_KEYCHAIN_SERVICE = "gemini-cli-oauth" +GEMINI_KEYCHAIN_ACCOUNT = "main-account" +CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com/v1internal" + +# OAuth client id/secret are discovered from the installed Gemini CLI (or env / +# oauth_creds.json) โ€” never hard-coded. Cached after the first successful lookup. +_GEMINI_OAUTH_CLIENT_CACHE: tuple[str, str] | None = None +_OAUTH_CLIENT_ID_RE = re.compile( + r"""OAUTH_CLIENT_ID\s*=\s*["']([^"']+\.apps\.googleusercontent\.com)["']""" +) +_OAUTH_CLIENT_SECRET_RE = re.compile( + r"""OAUTH_CLIENT_SECRET\s*=\s*["']([A-Za-z0-9_-]+)["']""" +) # Capturing Claude /usage spawns an interactive session (~15s), too heavy for # every 5-minute SwiftBar tick. Cached values are reused until this TTL passes; @@ -40,6 +69,14 @@ except ValueError: CLAUDE_TTL_SECONDS = 15 * 60 +# Gemini quota needs a keychain read plus 1-2 Google API round trips (and maybe +# a token refresh) โ€” too heavy to redo on every SwiftBar tick. Reuse cached +# values until this TTL passes; the Refresh menu action bypasses it with --force. +try: + GEMINI_TTL_SECONDS = float(os.environ.get("TOKEN_GHOST_GEMINI_TTL_MIN", "5")) * 60 +except ValueError: + GEMINI_TTL_SECONDS = 5 * 60 + # SwiftBar launches plugins with a minimal PATH, unlike the user's Terminal. # Add the common macOS Node/Homebrew locations so `claude` and `codex` can be found. COMMON_BIN_DIRS = [ @@ -51,6 +88,7 @@ "/sbin", str(Path.home() / ".npm-global" / "bin"), str(Path.home() / ".local" / "bin"), + str(Path.home() / ".bun" / "bin"), ] # nvm installs live under versioned directories; include them so nvm-managed # `claude`/`codex` binaries are found too (newest version first). @@ -100,6 +138,14 @@ class CodexWeek: reset_text: str | None = None +@dataclass +class GeminiBucket: + used_percent: int | None = None + reset_text: str | None = None + reset_expired: bool = False + model_id: str | None = None + + def strip_ansi(text: str) -> str: return ANSI_RE.sub("", text).replace("\r", "\n") @@ -406,6 +452,692 @@ def mtime(path: Path) -> float: return parsed +def _gemini_auth_type() -> str | None: + try: + settings = json.loads(GEMINI_SETTINGS.read_text()) + except Exception: + return None + auth = settings.get("security", {}).get("auth", {}) if isinstance(settings, dict) else {} + selected = auth.get("selectedType") if isinstance(auth, dict) else None + return str(selected) if selected else None + + +def _gemini_tier_key(model_id: str) -> str | None: + mid = model_id.lower() + if "flash-lite" in mid or "flash_lite" in mid: + return "flash_lite" + if "flash" in mid: + return "flash" + if "pro" in mid: + return "pro" + return None + + +def _http_json(method: str, url: str, *, headers: dict | None = None, body: dict | bytes | None = None, form: dict | None = None) -> dict: + data = None + req_headers = dict(headers or {}) + if form is not None: + data = urllib.parse.urlencode(form).encode("utf-8") + req_headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + elif body is not None: + data = json.dumps(body).encode("utf-8") if isinstance(body, dict) else body + req_headers.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url, data=data, headers=req_headers, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + raw = resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + # Do not attach upstream bodies โ€” they can leak into menus / --collect output. + raise RuntimeError(f"HTTP {exc.code}") from exc + if not raw.strip(): + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + + +def _gemini_keychain_ids() -> tuple[str, str]: + """Resolve Keychain service/account (same for load and save).""" + service = os.environ.get("TOKEN_GHOST_GEMINI_KEYCHAIN_SERVICE") or GEMINI_KEYCHAIN_SERVICE + account = os.environ.get("TOKEN_GHOST_GEMINI_KEYCHAIN_ACCOUNT") or GEMINI_KEYCHAIN_ACCOUNT + return service, account + + +def _gemini_writeback_enabled() -> bool: + """Whether Token Ghost may mutate Gemini CLI credential stores after refresh. + + Default off: refreshing tokens for retrieveUserQuota stays in-process only. + Opt in with TOKEN_GHOST_GEMINI_WRITEBACK=1 to write back to Keychain/file. + """ + return os.environ.get("TOKEN_GHOST_GEMINI_WRITEBACK", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _normalize_gemini_oauth_creds(data: dict) -> dict | None: + """Normalize legacy file creds and keychain HybridTokenStorage payloads.""" + if not isinstance(data, dict): + return None + # Legacy ~/.gemini/oauth_creds.json shape + if data.get("access_token") or data.get("refresh_token"): + return data + # Modern keychain shape: { serverName, token: { accessToken, refreshToken, expiresAt, ... } } + token = data.get("token") + if isinstance(token, dict) and (token.get("accessToken") or token.get("refreshToken")): + out: dict = { + "access_token": token.get("accessToken"), + "refresh_token": token.get("refreshToken"), + "token_type": token.get("tokenType") or "Bearer", + "scope": token.get("scope"), + } + expires_at = token.get("expiresAt") + if expires_at is not None: + try: + # Keychain may store ms or seconds; normalize to ms like google-auth. + value = float(expires_at) + out["expiry_date"] = int(value if value > 1e12 else value * 1000) + except (TypeError, ValueError): + pass + return out + return None + + +def _gemini_creds_to_keychain_payload(creds: dict) -> dict: + """Build the HybridTokenStorage JSON Gemini CLI expects in the keychain.""" + _, account = _gemini_keychain_ids() + return { + "serverName": account, + "token": { + "accessToken": creds.get("access_token"), + "refreshToken": creds.get("refresh_token") or None, + "tokenType": creds.get("token_type") or "Bearer", + "scope": creds.get("scope") or None, + "expiresAt": creds.get("expiry_date"), + }, + "updatedAt": int(time.time() * 1000), + } + + +def _load_gemini_oauth_from_keychain() -> dict | None: + """Read Gemini CLI OAuth from macOS Keychain (service gemini-cli-oauth). + + Newer Gemini CLI versions migrate oauth_creds.json into the keychain. + """ + if sys.platform != "darwin": + return None + service, account = _gemini_keychain_ids() + try: + result = subprocess.run( + [ + "security", + "find-generic-password", + "-s", + service, + "-a", + account, + "-w", + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except Exception: + return None + if result.returncode != 0: + return None + raw = (result.stdout or "").strip() + if not raw: + return None + try: + data = json.loads(raw) + except Exception: + return None + return _normalize_gemini_oauth_creds(data) if isinstance(data, dict) else None + + +def _load_gemini_oauth_from_file() -> dict | None: + try: + data = json.loads(GEMINI_OAUTH_CREDS.read_text()) + except Exception: + return None + return _normalize_gemini_oauth_creds(data) if isinstance(data, dict) else None + + +def _gemini_token_long_enough(value: object) -> bool: + return isinstance(value, str) and len(value) >= 20 + + +def _gemini_creds_look_valid(creds: dict | None) -> bool: + """Reject obviously corrupt tokens (e.g. accidental test writes).""" + if not isinstance(creds, dict): + return False + access = creds.get("access_token") + refresh = creds.get("refresh_token") + # Real Google access/refresh tokens are long. A short access with a long + # refresh is still usable (force-refresh), so either may qualify. + if _gemini_token_long_enough(access) or _gemini_token_long_enough(refresh): + return True + return False + + +def _load_gemini_oauth_creds() -> tuple[dict | None, str | None]: + """Load Gemini OAuth creds and report their store. + + Prefer Keychain when it holds real credentials โ€” that is Gemini CLI's + current source of truth. If Keychain is missing or corrupt, fall back to + legacy oauth_creds.json. + """ + keychain = _load_gemini_oauth_from_keychain() + if _gemini_creds_look_valid(keychain): + return keychain, "keychain" + file_creds = _load_gemini_oauth_from_file() + if _gemini_creds_look_valid(file_creds): + return file_creds, "file" + return None, None + + +def _save_gemini_oauth_to_file(creds: dict) -> None: + if not _gemini_creds_look_valid(creds): + return + try: + GEMINI_OAUTH_CREDS.parent.mkdir(parents=True, exist_ok=True) + payload = { + k: v + for k, v in creds.items() + if k in {"access_token", "refresh_token", "token_type", "scope", "expiry_date", "id_token"} + and v is not None + } + GEMINI_OAUTH_CREDS.write_text(json.dumps(payload, indent=2)) + GEMINI_OAUTH_CREDS.chmod(0o600) + except Exception: + pass + + +def _save_gemini_oauth_to_keychain(creds: dict) -> None: + if sys.platform != "darwin" or not _gemini_creds_look_valid(creds): + return + service, account = _gemini_keychain_ids() + payload = json.dumps(_gemini_creds_to_keychain_payload(creds), separators=(",", ":")) + try: + # -U updates an existing item; if missing, add a new one. + # NOTE: `security -w ` puts the token in argv, so it's visible + # to other local users via `ps` for the life of this subprocess. This + # is a limitation of the `security` CLI (no stdin form for the value), + # not something we can avoid while writeback is opted into. + result = subprocess.run( + [ + "security", + "add-generic-password", + "-U", + "-s", + service, + "-a", + account, + "-w", + payload, + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0: + return + subprocess.run( + [ + "security", + "delete-generic-password", + "-s", + service, + "-a", + account, + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + subprocess.run( + [ + "security", + "add-generic-password", + "-s", + service, + "-a", + account, + "-w", + payload, + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except Exception: + pass + + +def _persist_gemini_oauth_creds(creds: dict, source: str | None) -> None: + """Optionally write refreshed tokens back to the store they were loaded from. + + Default: no write-back (Token Ghost must not mutate Gemini CLI Keychain + unless the user opts in). TOKEN_GHOST_GEMINI_WRITEBACK=1 enables writing + to Keychain or oauth_creds.json matching `source`. + """ + if not _gemini_writeback_enabled(): + return + if source == "keychain": + _save_gemini_oauth_to_keychain(creds) + elif source == "file": + _save_gemini_oauth_to_file(creds) + + +def _gemini_package_roots() -> list[Path]: + """Resolve installed @google/gemini-cli package directories from the `gemini` binary.""" + roots: list[Path] = [] + seen: set[str] = set() + + def add(path: Path | None) -> None: + if path is None: + return + try: + resolved = path.resolve() + except OSError: + resolved = path + key = str(resolved) + if key in seen or not resolved.is_dir(): + return + seen.add(key) + roots.append(resolved) + + binary = shutil.which("gemini") + if binary: + bin_path = Path(binary) + try: + real = bin_path.resolve() + except OSError: + real = bin_path + # e.g. .../@google/gemini-cli/bundle/gemini.js โ†’ package root + for parent in [real.parent, *real.parents]: + pkg = parent / "package.json" + if pkg.is_file(): + try: + name = json.loads(pkg.read_text()).get("name") + except Exception: + name = None + if name == "@google/gemini-cli": + add(parent) + break + if parent.name == "@google" and (parent / "gemini-cli").is_dir(): + add(parent / "gemini-cli") + break + + home = Path.home() + for candidate in [ + home / ".bun" / "install" / "global" / "node_modules" / "@google" / "gemini-cli", + home / ".npm-global" / "lib" / "node_modules" / "@google" / "gemini-cli", + Path("/opt/homebrew/lib/node_modules/@google/gemini-cli"), + Path("/usr/local/lib/node_modules/@google/gemini-cli"), + Path("/opt/homebrew/opt/gemini-cli/libexec/lib/node_modules/@google/gemini-cli"), + Path("/usr/local/opt/gemini-cli/libexec/lib/node_modules/@google/gemini-cli"), + ]: + add(candidate) + + # npm global prefix (best-effort) + for cmd in (["npm", "root", "-g"],): + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=3, check=False) + except Exception: + continue + line = (out.stdout or "").strip().splitlines() + if not line: + continue + add(Path(line[0]) / "@google" / "gemini-cli") + + return roots + + +def _extract_oauth_client_from_bytes(data: bytes | str | memoryview) -> tuple[str, str] | None: + if isinstance(data, str): + text = data + else: + text = bytes(data).decode("utf-8", "ignore") + id_match = _OAUTH_CLIENT_ID_RE.search(text) + secret_match = _OAUTH_CLIENT_SECRET_RE.search(text) + if id_match and secret_match: + return id_match.group(1), secret_match.group(1) + return None + + +def _extract_oauth_client_from_file(path: Path) -> tuple[str, str] | None: + try: + size = path.stat().st_size + except OSError: + return None + if size <= 0 or size > 64 * 1024 * 1024: + return None + try: + # Small sources (oauth2.js): read whole file. Large bundle chunks: mmap. + if size < 2 * 1024 * 1024: + return _extract_oauth_client_from_bytes(path.read_bytes()) + with path.open("rb") as fh: + with mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) as mm: + return _extract_oauth_client_from_bytes(mm) + except Exception: + return None + + +def _scan_gemini_cli_for_oauth_client() -> tuple[str, str] | None: + """Pull OAUTH_CLIENT_ID/SECRET from the installed Gemini CLI package sources.""" + preferred_names = ("oauth2.js", "oauth2.ts", "oauth2.mjs", "oauth2.cjs") + for root in _gemini_package_roots(): + # Prefer dedicated oauth modules before scanning large bundle chunks. + hits: list[Path] = [] + for name in preferred_names: + hits.extend(root.rglob(name)) + for path in hits: + found = _extract_oauth_client_from_file(path) + if found: + return found + + bundle = root / "bundle" + search_dirs = [bundle] if bundle.is_dir() else [root] + for search_dir in search_dirs: + for path in sorted(search_dir.rglob("*")): + if not path.is_file(): + continue + if path.suffix not in {".js", ".mjs", ".cjs", ".ts"}: + continue + if path.name.endswith(".map"): + continue + found = _extract_oauth_client_from_file(path) + if found: + return found + return None + + +def _gemini_oauth_client(creds: dict | None = None) -> tuple[str, str] | None: + """Resolve Gemini CLI OAuth client id/secret without hard-coding. + + Order: + 1. TOKEN_GHOST_GEMINI_CLIENT_* / GEMINI_OAUTH_CLIENT_* env overrides + 2. client_id / client_secret fields inside oauth_creds.json (if present) + 3. Regex extract from the installed @google/gemini-cli package + """ + global _GEMINI_OAUTH_CLIENT_CACHE + if _GEMINI_OAUTH_CLIENT_CACHE: + return _GEMINI_OAUTH_CLIENT_CACHE + + env_id = os.environ.get("TOKEN_GHOST_GEMINI_CLIENT_ID") or os.environ.get("GEMINI_OAUTH_CLIENT_ID") + env_secret = os.environ.get("TOKEN_GHOST_GEMINI_CLIENT_SECRET") or os.environ.get( + "GEMINI_OAUTH_CLIENT_SECRET" + ) + if env_id and env_secret: + _GEMINI_OAUTH_CLIENT_CACHE = (env_id, env_secret) + return _GEMINI_OAUTH_CLIENT_CACHE + + if creds: + cred_id = creds.get("client_id") or creds.get("clientId") + cred_secret = creds.get("client_secret") or creds.get("clientSecret") + if cred_id and cred_secret: + _GEMINI_OAUTH_CLIENT_CACHE = (str(cred_id), str(cred_secret)) + return _GEMINI_OAUTH_CLIENT_CACHE + + found = _scan_gemini_cli_for_oauth_client() + if found: + _GEMINI_OAUTH_CLIENT_CACHE = found + return _GEMINI_OAUTH_CLIENT_CACHE + return None + + +def _gemini_access_token(creds: dict, source: str | None = None) -> str | None: + token = creds.get("access_token") + expiry_ms = creds.get("expiry_date") + refresh_token = creds.get("refresh_token") + # Refresh ~2 minutes before expiry (expiry_date is epoch milliseconds), + # or immediately when the access token looks corrupt/truncated. + needs_refresh = False + if not _gemini_token_long_enough(token): + needs_refresh = True + elif expiry_ms is not None: + try: + needs_refresh = float(expiry_ms) <= (time.time() * 1000) + 120_000 + except (TypeError, ValueError): + needs_refresh = True + + if token and _gemini_token_long_enough(token) and not needs_refresh: + return str(token) + if not _gemini_token_long_enough(refresh_token): + return None + + client = _gemini_oauth_client(creds) + if not client: + return None + client_id, client_secret = client + + try: + refreshed = _http_json( + "POST", + "https://oauth2.googleapis.com/token", + form={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": str(refresh_token), + "grant_type": "refresh_token", + }, + ) + except Exception: + # Never return a stale/short access token after a failed refresh. + return None + + access = refreshed.get("access_token") + if not _gemini_token_long_enough(access): + return None + creds["access_token"] = access + if refreshed.get("refresh_token"): + creds["refresh_token"] = refreshed["refresh_token"] + if refreshed.get("id_token"): + creds["id_token"] = refreshed["id_token"] + expires_in = refreshed.get("expires_in") + if expires_in is not None: + try: + creds["expiry_date"] = int(time.time() * 1000) + int(expires_in) * 1000 + except (TypeError, ValueError): + pass + _persist_gemini_oauth_creds(creds, source) + return str(access) + + +def _code_assist_post(access_token: str, method: str, body: dict) -> dict: + return _http_json( + "POST", + f"{CODE_ASSIST_ENDPOINT}:{method}", + headers={"Authorization": f"Bearer {access_token}", "User-Agent": "token-ghost"}, + body=body, + ) + + +def _gemini_project_id(access_token: str) -> str | None: + try: + data = _code_assist_post( + access_token, + "loadCodeAssist", + { + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + } + }, + ) + except Exception: + return None + project = data.get("cloudaicompanionProject") or data.get("cloudaicompanionProjectId") + return str(project) if project else None + + +def parse_gemini_quota_buckets(buckets: list | None) -> dict: + """Collapse model buckets into Pro / Flash / Flash Lite like Gemini CLI.""" + empty = { + "pro": asdict(GeminiBucket()), + "flash": asdict(GeminiBucket()), + "flash_lite": asdict(GeminiBucket()), + } + if not isinstance(buckets, list): + return empty + + best: dict[str, dict] = {} + for bucket in buckets: + if not isinstance(bucket, dict): + continue + model_id = bucket.get("modelId") or bucket.get("model_id") + remaining = bucket.get("remainingFraction") + if remaining is None: + remaining = bucket.get("remaining_fraction") + if not model_id or remaining is None: + continue + tier = _gemini_tier_key(str(model_id)) + if not tier: + continue + try: + remaining_f = float(remaining) + except (TypeError, ValueError): + continue + prev = best.get(tier) + if prev is None or remaining_f < prev["remaining"]: + best[tier] = { + "remaining": remaining_f, + "reset": bucket.get("resetTime") or bucket.get("reset_time"), + "model_id": str(model_id), + } + + result = empty + for tier, info in best.items(): + used = int(round(max(0.0, min(1.0, 1.0 - info["remaining"])) * 100)) + reset_epoch = _parse_iso_timestamp(info["reset"] if isinstance(info["reset"], str) else None) + if reset_epoch is None and isinstance(info["reset"], (int, float)): + try: + reset_epoch = float(info["reset"]) + if reset_epoch > 1e12: + reset_epoch /= 1000.0 + except Exception: + reset_epoch = None + expired = _epoch_is_past(reset_epoch) + # Daily windows: once past, roll forward by 24h for a projected next reset. + projected = _project_reset_epoch(reset_epoch, 24 * 60) if reset_epoch is not None else None + result[tier] = asdict( + GeminiBucket( + used_percent=0 if expired else used, + reset_text=_format_reset_epoch(projected, weekly=False), + reset_expired=expired, + model_id=info["model_id"], + ) + ) + return result + + +def collect_gemini() -> dict: + """Read Gemini daily quotas from Code Assist (Gemini CLI Google login). + + Needs Google OAuth credentials from macOS Keychain (`gemini-cli-oauth`) or + legacy ~/.gemini/oauth_creds.json. API-key / Vertex auth do not populate + Pro/Flash daily buckets โ€” the menu shows a setup hint instead. + """ + cli_missing = shutil.which("gemini") is None + auth_type = _gemini_auth_type() + empty = parse_gemini_quota_buckets(None) + empty["cli_missing"] = cli_missing + empty["auth_type"] = auth_type + + creds, cred_source = _load_gemini_oauth_creds() + if not creds: + if auth_type == "gemini-api-key": + empty["error"] = ( + "Currently signed in with API key โ€” Pro/Flash daily bars need " + "Google login (/auth โ†’ Login with Google)" + ) + elif auth_type == "vertex-ai": + empty["error"] = ( + "Currently signed in with Vertex AI โ€” Pro/Flash daily bars need " + "Google login (/auth โ†’ Login with Google)" + ) + elif cli_missing: + empty["error"] = "gemini CLI not found" + else: + empty["error"] = "Gemini Google login required (run gemini, then /auth โ†’ Login with Google)" + return empty + + access_token = _gemini_access_token(creds, source=cred_source) + if not access_token: + empty["error"] = "Gemini OAuth token missing or expired โ€” re-login with /auth" + return empty + + project_id = _gemini_project_id(access_token) + body: dict = {} + if project_id: + body["project"] = project_id + + try: + quota = _code_assist_post(access_token, "retrieveUserQuota", body) + except Exception as exc: + msg = str(exc) + if "UNSUPPORTED_CLIENT" in msg or "IneligibleTier" in msg: + empty["error"] = ( + "Gemini CLI Google login no longer serves quota for this account tier โ€” " + "use a supported Workspace/Enterprise login or check Gemini CLI docs" + ) + else: + # Prefer status code only โ€” avoid dumping upstream error bodies into the menu. + code_match = re.search(r"HTTP\s+(\d{3})", msg) + code = code_match.group(1) if code_match else "error" + empty["error"] = f"Gemini quota request failed (HTTP {code})" + return empty + + buckets = quota.get("buckets") if isinstance(quota, dict) else None + parsed = parse_gemini_quota_buckets(buckets if isinstance(buckets, list) else None) + parsed["cli_missing"] = cli_missing + parsed["auth_type"] = auth_type + parsed["cred_source"] = cred_source + parsed["project_id"] = project_id + parsed["source"] = "Code Assist retrieveUserQuota" + parsed["source_epoch"] = time.time() + if parsed["pro"]["used_percent"] is None and parsed["flash"]["used_percent"] is None: + parsed["error"] = "Gemini quota buckets empty โ€” open gemini once after Google login" + return parsed + return parsed + + +def _gemini_has_data(gemini: dict) -> bool: + return ( + gemini.get("pro", {}).get("used_percent") is not None + or gemini.get("flash", {}).get("used_percent") is not None + or gemini.get("flash_lite", {}).get("used_percent") is not None + ) + + +def collect_gemini_cached(cache: dict, force: bool = False) -> dict: + """Return Gemini quota, hitting Code Assist only when the cached value is + missing or older than GEMINI_TTL_SECONDS (or on --force).""" + now = time.time() + cached = cache.get("gemini") or {} + cached_age = now - float(cached.get("collected_at") or 0) + if not force and _gemini_has_data(cached) and cached_age < GEMINI_TTL_SECONDS: + return cached + + gemini = collect_gemini() + gemini["collected_at"] = now + if _gemini_has_data(gemini): + return gemini + if _gemini_has_data(cached): + # Keep the last good value (and its collected_at, so the next tick + # retries) instead of blanking the menu on one failed refresh. + return cached | {"error": gemini.get("error") or "Gemini quota refresh failed"} + return gemini + + def load_cache() -> dict: try: return json.loads(CACHE_PATH.read_text()) @@ -418,6 +1150,10 @@ def save_cache(data: dict) -> None: tmp = CACHE_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False)) tmp.replace(CACHE_PATH) + try: + CACHE_PATH.chmod(0o600) + except Exception: + pass def _claude_has_data(claude: dict) -> bool: @@ -453,31 +1189,12 @@ def collect_all(force: bool = False) -> dict: "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), "claude": collect_claude_cached(cache, force=force), "codex": collect_codex(), + "gemini": collect_gemini_cached(cache, force=force), } save_cache(result) return result -def severity_claude(used: int | None) -> str: - if used is None: - return "gray" - if used >= 90: - return "red" - if used >= 70: - return "orange" - return "green" - - -def severity_codex_left(left: int | None) -> str: - if left is None: - return "gray" - if left <= 19: - return "red" - if left <= 49: - return "orange" - return "green" - - def pct(v: int | None, suffix: str = "%") -> str: return "?" if v is None else f"{v}{suffix}" @@ -584,6 +1301,15 @@ def data_age_line(text: str) -> str: return text + style(color="#666666", size=11, active=False) +def gemini_usage_line(label: str, bucket: dict) -> str: + used = bucket.get("used_percent") + if used is None: + return f"{label}: Not connected" + style(size=12) + if bucket.get("reset_expired"): + return usage_line(label, 0, reset_suffix(bucket.get("reset_text"))) + return usage_line(label, used, reset_suffix(bucket.get("reset_text"))) + + def setup_hint(tool: str, error: str | None) -> list[str]: """User-facing setup/help rows for missing or unreadable CLI state.""" if not error: @@ -597,6 +1323,15 @@ def setup_hint(tool: str, error: str | None) -> list[str]: "Run Claude setup in Terminal | bash='/bin/sh' param1='-lc' param2='command -v claude >/dev/null || npm install -g @anthropic-ai/claude-code; echo \"If Claude Code asks, log in with /login, then quit.\"; claude' terminal=true", "Copy install command | bash='/bin/sh' param1='-lc' param2='printf %s \"npm install -g @anthropic-ai/claude-code\" | pbcopy' terminal=false", ] + if tool == "gemini": + return [ + "Gemini setup needed" + style(color="orange", size=12), + "Pro/Flash bars need Google account OAuth (not API key)" + style(size=12), + "1. Run: gemini" + style(size=12), + "2. /auth โ†’ Login with Google" + style(size=12), + "3. Quit, then Refresh this menu" + style(size=12), + "Switch Gemini to Google login | bash='/bin/sh' param1='-lc' param2='echo \"In Gemini: type /auth โ†’ choose Login with Google, then quit.\"; gemini' terminal=true", + ] return [ "Codex setup needed" + style(color="orange", size=12), "To show Codex usage:" + style(size=12), @@ -614,6 +1349,10 @@ def render_swiftbar(data: dict) -> str: codex = data.get("codex", {}) five = codex.get("five_hour", {}) codex_week = codex.get("week_all", {}) + gemini = data.get("gemini", {}) + gemini_pro = gemini.get("pro", {}) + gemini_flash = gemini.get("flash", {}) + gemini_flash_lite = gemini.get("flash_lite", {}) current = session.get("used_percent") title = top_title(current) @@ -645,6 +1384,26 @@ def render_swiftbar(data: dict) -> str: lines.append("Codex CLI not found โ€” showing last session log" + style(color="orange", size=11, active=False)) lines.extend(setup_hint("codex", codex.get("error"))) + lines.append("๐Ÿ’œ Gemini" + style(color="#111111", size=12)) + has_gemini = ( + gemini_pro.get("used_percent") is not None + or gemini_flash.get("used_percent") is not None + or gemini_flash_lite.get("used_percent") is not None + ) + if gemini.get("error") and not has_gemini: + lines.extend(setup_hint("gemini", gemini.get("error"))) + else: + lines.append(gemini_usage_line("Pro", gemini_pro)) + lines.append(gemini_usage_line("Flash", gemini_flash)) + if gemini_flash_lite.get("used_percent") is not None: + lines.append(gemini_usage_line("Flash Lite", gemini_flash_lite)) + gemini_age = age_text(gemini.get("source_epoch")) + if gemini_age: + lines.append(data_age_line(f"From Code Assist, {gemini_age}")) + if gemini.get("cli_missing"): + lines.append("gemini CLI not found โ€” showing OAuth quota" + style(color="orange", size=11, active=False)) + lines.extend(setup_hint("gemini", gemini.get("error"))) + script_path = str(Path.home() / ".token-ghost" / "token_ghost.py") lines += [ "---", @@ -653,6 +1412,7 @@ def render_swiftbar(data: dict) -> str: "Setup guide | bash='/bin/sh' param1='-c' param2='open \"$HOME/.token-ghost/README.md\" 2>/dev/null || open https://github.com/zoeymakes/token-ghost#install' terminal=false", "Open Claude | bash='/bin/sh' param1='-c' param2='open -a Claude 2>/dev/null || open https://claude.ai' terminal=false", "Open Codex | bash='/bin/sh' param1='-c' param2='open -a Codex 2>/dev/null || open -a ChatGPT 2>/dev/null || open https://chatgpt.com/codex' terminal=false", + "Open Gemini | bash='/bin/sh' param1='-c' param2='open https://gemini.google.com' terminal=false", ] return "\n".join(lines)