Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ swift test # ユニットテスト
| `status.claude.com`, `status.openai.com` | プロバイダ障害バナー | statuspage の要約;表示専用 — 設定でオフにできます |
| `api.github.com` | アップデート確認 | 最新リリースのタグ;起動時とポップオーバーを開いた時 |

ログが**上記の既定パスの外**にある場合は、**設定 → 詳細 → 追加スキャンフォルダ**にそのフォルダを追加します。先にプロバイダーを選んでください — フォルダはそのプロバイダーだけが解析するので、Gemini 欄に Claude のログを指定するとトークンの帰属が壊れます。追加フォルダは既定の場所に*足すだけ*で、置き換えません。

## プライバシー & 権限

- **オンデバイス。** トークン使用量はローカルの Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI データから直接読み取ります。使用量のアップロードも、モデルの推論実行も行いません。
Expand Down
2 changes: 2 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ swift test # 단위 테스트
| `status.claude.com`, `status.openai.com` | 프로바이더 장애 배너 | statuspage 요약; 표시 전용 — 설정에서 끌 수 있음 |
| `api.github.com` | 업데이트 확인 | 최신 릴리스 태그; 기동 시와 팝오버를 열 때 |

로그가 **위 기본 경로 밖**에 있으면 **설정 → 고급 → 추가 스캔 폴더**에 그 폴더를 넣습니다. 프로바이더를 먼저 고르세요 — 폴더는 그 프로바이더만 파싱하므로, Gemini 칸에 Claude 로그를 넣으면 토큰이 잘못 귀속됩니다. 추가 폴더는 기본 위치에 *더해질* 뿐 대체하지 않습니다.

## 프라이버시 & 권한

- **온디바이스.** 토큰 사용량은 로컬 Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI 데이터에서 직접 읽습니다. 사용량을 업로드하거나 모델 turn을 실행하지 않습니다.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ swift test # unit tests
| `status.claude.com`, `status.openai.com` | provider incident banner | statuspage summary; display only — turn it off in Settings |
| `api.github.com` | update check | latest release tag; on launch and when the popover opens |

If a provider's logs live **outside** those built-in paths, add the folder in **Settings → Advanced → Additional scan folders**. Pick the provider first — each folder is parsed only by that provider, so pointing a Gemini field at Claude logs would mis-attribute tokens. Extra folders are added to the built-in locations; they never replace them.

## Privacy & permissions

- **On-device.** Token usage is read directly from local Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, and Kiro CLI data. The app never uploads usage or runs model turns.
Expand Down
118 changes: 118 additions & 0 deletions Sources/PokeTokenBar/Core/CustomScanRoots.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Darwin
import Foundation

/// Per-provider extra scan folders from Settings (#177).
///
/// One list per provider, stored under `customScanRoots.<id>`. A flat list shared
/// across readers is unsafe — `jsonlFiles(allowJSON:)` exists specifically so Gemini
/// does not ingest Claude `.meta.json`. Custom roots only *add* to curated defaults;
/// an ancestor extra is dropped so `normalizedRoots` cannot fold a hidden default
/// away (#162-B: scanning `~` with `skipsHiddenFiles` silently zeros usage).
enum CustomScanRoots {

static func defaultsKey(for providerID: String) -> String {
"customScanRoots.\(providerID)"
}

static func storedValue(for providerID: String, defaults: UserDefaults = .standard) -> String? {
guard let raw = defaults.string(forKey: defaultsKey(for: providerID)) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : raw
}

/// Comma/newline split, tilde expansion, per-segment `*`/`?`/`[]` via `fnmatch`.
/// Entries point at log roots themselves (no `/projects` suffix). Missing paths
/// are not errors — a pattern can be registered before the instance exists.
static func expand(_ raw: String) -> [URL] {
let fm = FileManager.default
var out: [URL] = []
for part in raw.split(whereSeparator: { $0 == "," || $0.isNewline }) {
let pattern = part.trimmingCharacters(in: .whitespaces)
guard !pattern.isEmpty else { continue }
let absolute = NSString(string: pattern).expandingTildeInPath
guard absolute.hasPrefix("/") else { continue }

var paths = [""]
for segment in absolute.split(separator: "/").map(String.init) {
if segment.contains("*") || segment.contains("?") || segment.contains("[") {
var next: [String] = []
for base in paths {
guard let names = try? fm.contentsOfDirectory(atPath: base.isEmpty ? "/" : base)
else { continue }
next.append(contentsOf: names.sorted()
.filter { fnmatch(segment, $0, 0) == 0 }
.map { base + "/" + $0 })
}
paths = next
} else {
paths = paths.map { $0 + "/" + segment }
}
}
if paths == [""] { paths = ["/"] }
var isDir: ObjCBool = false
out.append(contentsOf: paths
.filter { fm.fileExists(atPath: $0, isDirectory: &isDir) && isDir.boolValue }
.map { URL(fileURLWithPath: $0) })
}
return out
}

/// Curated defaults first; extras that would evict a default are dropped; then fold.
static func union(defaults: [URL], extraRaw: String?) -> [URL] {
let protected = LocalUsageReader.normalizedRoots(defaults)
guard let extraRaw else { return protected }
let added = expand(extraRaw).filter { extra in
!wouldEvict(extra, protected: protected)
}
return LocalUsageReader.normalizedRoots(protected + added)
}

/// Folded extras that are not one of the curated defaults — the number Settings shows.
static func survivingExtraCount(defaults: [URL], extraRaw: String) -> Int {
let protected = Set(LocalUsageReader.normalizedRoots(defaults).map(normalizedPath))
let united = union(defaults: defaults, extraRaw: extraRaw)
return united.filter { !protected.contains(normalizedPath($0)) }.count
}

/// Production curated roots for the Settings match count. Uses the same
/// env/home resolution as the readers so the number matches what will be scanned.
static func curatedRoots(for providerID: String) -> [URL] {
switch providerID {
case "claude_code":
return LocalUsageReader.computeClaudeProjectRoots(customRootsValue: nil)
case "codex":
return LocalUsageReader.codexSessionRoots(customRootsValue: nil)
case "gemini":
return LocalUsageReader.geminiScanRoots(customRootsValue: nil)
case "grok":
return LocalUsageReader.grokSessionRoots(customRootsValue: nil)
case "antigravity":
return LocalAntigravityUsageReader.resolvedRoots(customRootsValue: nil)
case "opencode":
return LocalAdditionalUsageReader.openCodeRoots(customRootsValue: nil)
case "hermes":
return LocalAdditionalUsageReader.hermesRoots(customRootsValue: nil)
case "cursor":
return LocalAdditionalUsageReader.cursorRoots(customRootsValue: nil)
case "copilot":
return LocalAdditionalUsageReader.copilotRoots(customRootsValue: nil)
case "kiro":
return LocalAdditionalUsageReader.kiroRoots(customRootsValue: nil)
default:
return []
}
}

private static func wouldEvict(_ extra: URL, protected: [URL]) -> Bool {
let extraPath = normalizedPath(extra)
return protected.contains { def in
let d = normalizedPath(def)
if extraPath == "/" { return d != extraPath }
return d != extraPath && d.hasPrefix(extraPath + "/")
}
}

private static func normalizedPath(_ url: URL) -> String {
url.resolvingSymlinksInPath().standardizedFileURL.path.lowercased()
}
}
83 changes: 77 additions & 6 deletions Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ private actor LocalAdditionalUsageCache {

private var cached: [LocalAdditionalSource: Cached] = [:]
private var inFlight: [LocalAdditionalSource: Task<ScanResult, Never>] = [:]
/// Bumped by `invalidate()` so a scan that started on old roots cannot republish.
private var epoch = 0

func invalidate() {
epoch += 1
cached = [:]
inFlight = [:]
}

func entries(for source: LocalAdditionalSource) async -> [LocalUsageReader.Entry] {
let now = Date()
Expand Down Expand Up @@ -237,8 +245,12 @@ private actor LocalAdditionalUsageCache {
highWaterByPath: loaded.highWaterByPath)
}
}
let startEpoch = epoch
inFlight[source] = task
let result = await task.value
if startEpoch != epoch {
return result.entries
}
inFlight[source] = nil
cached[source] = Cached(
loadedAt: now, monthKey: monthKey, entries: result.entries,
Expand All @@ -253,16 +265,45 @@ private actor LocalAdditionalUsageCache {
enum LocalAdditionalUsageReader {
typealias Object = [String: Any]

/// Drop the 30s hit so a newly saved extra folder is scanned on the next refresh (#177).
static func invalidateScanCache() async {
await LocalAdditionalUsageCache.shared.invalidate()
}

static var defaultOpenCodeRoots: [URL] {
environmentPaths("OPENCODE_DATA_DIR")
?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/opencode")]
}

static func openCodeRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
let curated = environmentPaths("OPENCODE_DATA_DIR")
?? [home.appendingPathComponent(".local/share/opencode")]
return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue)
}

static var defaultHermesRoots: [URL] {
environmentPaths("HERMES_HOME")
?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".hermes")]
}

static func hermesRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
let curated = environmentPaths("HERMES_HOME")
?? [home.appendingPathComponent(".hermes")]
return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue)
}

static func openCodeEntries(
modifiedSince: Date,
roots: [URL]? = nil
) -> [LocalUsageReader.Entry] {
let sourceRoots = roots ?? defaultOpenCodeRoots
let sourceRoots = roots ?? openCodeRoots(
customRootsValue: CustomScanRoots.storedValue(for: "opencode"))
var entries: [LocalUsageReader.Entry] = []
for root in sourceRoots {
if let database = preferredOpenCodeDatabase(in: root) {
Expand All @@ -283,8 +324,8 @@ enum LocalAdditionalUsageReader {
modifiedSince: Date,
roots: [URL]? = nil
) -> [LocalUsageReader.Entry] {
let sourceRoots = roots ?? environmentPaths("HERMES_HOME")
?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".hermes")]
let sourceRoots = roots ?? hermesRoots(
customRootsValue: CustomScanRoots.storedValue(for: "hermes"))
var seen = Set<String>()
var entries: [LocalUsageReader.Entry] = []
for root in sourceRoots {
Expand Down Expand Up @@ -568,14 +609,25 @@ enum LocalAdditionalUsageReader {
]
}

static func cursorRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
let curated = environmentPaths("CURSOR_DATA_DIR") ?? [
home.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage"),
home.appendingPathComponent("Library/Application Support/Cursor Nightly/User/globalStorage"),
]
return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue)
}

static func cursorEntries(
modifiedSince: Date,
afterRowID: Int64 = 0,
afterRowIDByPath: [String: Int64]? = nil,
roots: [URL]? = nil
) -> CursorLoadResult {
scanIncrementalStores(
roots: roots ?? defaultCursorRoots,
roots: roots ?? cursorRoots(customRootsValue: CustomScanRoots.storedValue(for: "cursor")),
modifiedSince: modifiedSince,
afterRowID: afterRowID,
afterRowIDByPath: afterRowIDByPath,
Expand Down Expand Up @@ -693,6 +745,15 @@ enum LocalAdditionalUsageReader {
?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".copilot")]
}

static func copilotRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
let curated = environmentPaths("COPILOT_HOME")
?? [home.appendingPathComponent(".copilot")]
return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue)
}

/// Read Copilot CLI usage rows newer than `modifiedSince`.
///
/// Every row in `assistant_usage_events` is one billed API call, including the ones a
Expand All @@ -708,7 +769,7 @@ enum LocalAdditionalUsageReader {
roots: [URL]? = nil
) -> CopilotLoadResult {
scanIncrementalStores(
roots: roots ?? defaultCopilotRoots,
roots: roots ?? copilotRoots(customRootsValue: CustomScanRoots.storedValue(for: "copilot")),
modifiedSince: modifiedSince,
afterRowID: afterRowID,
afterRowIDByPath: afterRowIDByPath,
Expand Down Expand Up @@ -803,6 +864,15 @@ enum LocalAdditionalUsageReader {
.appendingPathComponent("Library/Application Support/kiro-cli")]
}

static func kiroRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
let curated = environmentPaths("KIRO_CLI_HOME")
?? [home.appendingPathComponent("Library/Application Support/kiro-cli")]
return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue)
}

/// Read Kiro CLI usage turns newer than `modifiedSince`.
///
/// Kiro persists a conversation as one row whose `value` column holds the *entire*
Expand Down Expand Up @@ -833,7 +903,8 @@ enum LocalAdditionalUsageReader {
knownSignatures: [String: (mtime: Date, size: Int)],
roots: [URL]? = nil
) -> (entries: [LocalUsageReader.Entry], signatures: [String: (mtime: Date, size: Int)]) {
let sourceRoots = roots ?? defaultKiroRoots
let sourceRoots = roots ?? kiroRoots(
customRootsValue: CustomScanRoots.storedValue(for: "kiro"))
var entries: [LocalUsageReader.Entry] = []
var signatures: [String: (mtime: Date, size: Int)] = [:]
for root in sourceRoots {
Expand Down
33 changes: 25 additions & 8 deletions Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,38 @@ enum LocalAntigravityUsageReader {

/// Known conversation directories across Antigravity editions (2.0/Core, CLI, IDE).
/// The directory is absent unless the respective Antigravity flavor ran.
static var defaultRoots: [URL] {
let home = FileManager.default.homeDirectoryForCurrentUser
return [
static func defaultRoots(
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
[
home.appendingPathComponent(".gemini/antigravity/conversations"),
home.appendingPathComponent(".gemini/antigravity-cli/conversations"),
home.appendingPathComponent(".gemini/antigravity-ide/conversations"),
]
}

static var defaultRoots: [URL] { defaultRoots() }

/// Primary default directory for single-root callers and backwards compatibility.
static var defaultRoot: URL {
defaultRoots[0]
}

static func resolvedRoots(
customRootsValue: String? = nil,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
CustomScanRoots.union(
defaults: defaultRoots(home: home),
extraRaw: customRootsValue)
}

/// Usage rows whose `created_at` falls at or after `modifiedSince`.
static func entries(modifiedSince: Date, roots: [URL]? = nil) -> [LocalUsageReader.Entry] {
let scanned = scan(roots: roots ?? defaultRoots, modifiedSince: modifiedSince, known: [:])
let scanned = scan(
roots: roots ?? resolvedRoots(
customRootsValue: CustomScanRoots.storedValue(for: "antigravity")),
modifiedSince: modifiedSince, known: [:])
// The one place the side effects live. `AppLog.write` returns early outside the bundled
// app, so the decisions above it are kept pure and tested on their own.
for line in scanned.log { AppLog.write(line) }
Expand All @@ -76,7 +91,10 @@ enum LocalAntigravityUsageReader {

/// Single-root overload for backwards compatibility and tests.
static func entries(modifiedSince: Date, root: URL?) -> [LocalUsageReader.Entry] {
entries(modifiedSince: modifiedSince, roots: root.map { [$0] } ?? defaultRoots)
entries(
modifiedSince: modifiedSince,
roots: root.map { [$0] } ?? resolvedRoots(
customRootsValue: CustomScanRoots.storedValue(for: "antigravity")))
}

/// One conversation store's rows, valid for as long as its `(mtime, size)` hold. The rows
Expand Down Expand Up @@ -666,10 +684,9 @@ actor LocalAntigravityUsageCache {
// Join rather than start a second scan, and leave the log to the owner.
scanned = await inFlight.value
} else {
let targetRoots = self.roots ?? LocalAntigravityUsageReader.defaultRoots
let targetRoots = self.roots ?? LocalAntigravityUsageReader.resolvedRoots(
customRootsValue: CustomScanRoots.storedValue(for: "antigravity"))
let known = blobs
// Nothing awaits between the miss above and this assignment — that is what stops two
// callers from both starting a scan.
let task = Task.detached(priority: .utility) {
LocalAntigravityUsageReader.scan(roots: targetRoots, modifiedSince: since, known: known)
}
Expand Down
Loading