Skip to content
Open
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
38 changes: 25 additions & 13 deletions CreditClockApp/ProviderSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ struct ProviderSettingsView: View {
private let credentialStore: CredentialStore = KeychainCredentialStore()
private let connectionStore = ProviderConnectionStore()

private var isHookCacheInstalled: Bool {
FileManager.default.fileExists(atPath: SharedFallbackPath.url(fileName: "usage-cache.json").path)
}

var body: some View {
Form {
Section("Local Data Access") {
Expand Down Expand Up @@ -98,7 +102,7 @@ struct ProviderSettingsView: View {
@ViewBuilder
private func localProviderCard(for provider: ProviderId, id: String) -> some View {
LabeledContent("Auth Method") {
Text(provider == .openai ? "Local Codex files" : "Claude local cache / OAuth")
Text(provider == .openai ? "Local Codex files" : "Claude Hook cache / OAuth")
.foregroundStyle(.secondary)
}

Expand Down Expand Up @@ -129,6 +133,21 @@ struct ProviderSettingsView: View {
}

if provider == .anthropic {
if !isHookCacheInstalled {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Claude Code hook이 설치되지 않았습니다.")
.font(.caption)
Text("Terminal에서 ./scripts/install-claude-hook.sh 를 실행하면 OAuth 없이도 사용량을 자동으로 가져올 수 있습니다.")
.font(.caption)
}
} icon: {
Image(systemName: "info.circle")
.font(.caption)
}
.foregroundStyle(.secondary)
}

HStack {
Button("Auth Status") {
Task { await runClaudeAuthStatus() }
Expand Down Expand Up @@ -515,29 +534,22 @@ struct ProviderSettingsView: View {
}()

if fetchError == nil {
var parts = ["Claude OAuth usable (direct API fetch succeeded)."]
var parts = ["Claude usage cache read succeeded."]
if localStatus.source != .none {
parts.append("local source: \(sourceLabel)")
if let expiresAt = localStatus.expiresAt {
parts.append("expires: \(dateText(expiresAt))")
}
} else {
parts.append("local token source not found (may still be available via keychain prompt/session).")
parts.append("Local credentials: \(sourceLabel).")
}
return parts.joined(separator: " ")
}

if localStatus.source == .none {
return "Claude local OAuth credentials not found. Terminal에서 `claude auth login` 실행 후 다시 `Auth Status` 또는 `Test`를 눌러주세요."
return "Claude usage cache not found. Claude Code 세션이 활성 상태인지 확인하세요. `./scripts/install-claude-hook.sh`로 hook을 설치하거나 Terminal에서 `claude auth login` 후 다시 시도해주세요."
}

var parts = [
"Claude local OAuth found (\(sourceLabel)) but fetch failed:",
"Local credentials found (\(sourceLabel)) but cache read failed:",
fetchError?.localizedDescription ?? "unknown error"
]
if let expiresAt = localStatus.expiresAt {
parts.append("(expires \(dateText(expiresAt))).")
}
parts.append("Claude Code 세션이 활성 상태인지 확인하세요.")
return parts.joined(separator: " ")
}

Expand Down
31 changes: 30 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
| Provider | 데이터 소스 | 앱에서 설정 방법 |
|---|---|---|
| Codex (`OpenAI`) | 로컬 `~/.codex` 사용량 캐시/세션 로그, JWT fallback | 로컬 폴더 권한 승인 + `Connect` |
| Claude (`Anthropic`) | 로컬 `~/.claude/plugins/oh-my-claudecode/.usage-cache.json` 또는 Anthropic OAuth usage endpoint | 로컬 폴더 권한 승인 + `Connect` |
| Claude (`Anthropic`) | 1) `~/.creditclock/usage-cache.json` (hook 경유) 2) `~/.claude/plugins/oh-my-claudecode/.usage-cache.json` (OMC 캐시) 3) Anthropic OAuth usage endpoint | 로컬 폴더 권한 승인 + `Connect` |
| Gemini | 로컬 `~/.gemini/oauth_creds.json` (CLI OAuth 쿼터) 또는 Gemini API 키 fallback | CLI OAuth를 위한 로컬 폴더 권한 승인, 또는 API 키 저장 + 활성화 |

## 설치 방법 (macOS)
Expand All @@ -61,6 +61,35 @@ open CreditClock.xcodeproj

Xcode에서 `CreditClock` 스킴을 실행하세요.

### Hook 설정 (선택)

CreditClock에는 Claude Code 세션 중 구독 사용량 데이터를 자동으로 캐시하는 hook이 포함되어 있습니다. Claude 사용량 추적을 위한 가장 빠른 데이터 소스로, OAuth나 활성 Claude Code 세션 없이도 `~/.creditclock/usage-cache.json`에 사용량을 기록합니다.

**설치:**

```bash
./scripts/install-claude-hook.sh
```

**제거:**

```bash
./scripts/install-claude-hook.sh remove
```

**Hook이 하는 일:**

- Claude Code 세션 중 자동으로 실행됩니다 (`SessionStart`, `PostToolUse` 이벤트).
- macOS Keychain에서 OAuth 토큰을 읽어 Anthropic 사용량 API를 호출합니다.
- 결과를 `~/.creditclock/usage-cache.json`에 저장하여 CreditClock이 읽을 수 있게 합니다.

**설치 시 수정되는 파일:**

- `~/.claude/settings.json` — `hooks.PostToolUse`와 `hooks.SessionStart`에 hook 항목 추가.
- `~/.creditclock/hooks/` — `fetch-usage.py`와 `fetch-usage.sh` 스크립트 복사.

> **보안 안내:** Hook은 macOS Keychain에서 Claude OAuth 토큰을 읽어 Anthropic API 사용량 엔드포인트를 호출합니다. 설치 전 `scripts/` 디렉토리의 스크립트를 확인해주세요.

## 백그라운드 동작 방식

- CreditClock는 메뉴바 에이전트로 동작합니다 (Dock 아이콘/상시 메인 창 없음).
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
| Provider | Data Source | Setup in App |
|---|---|---|
| Codex (`OpenAI`) | Local `~/.codex` usage cache/session logs, with JWT fallback | Grant local folder access + click `Connect` |
| Claude (`Anthropic`) | Local `~/.claude/plugins/oh-my-claudecode/.usage-cache.json` or Anthropic OAuth usage endpoint | Grant local folder access + click `Connect` |
| Claude (`Anthropic`) | 1) `~/.creditclock/usage-cache.json` (via hook) 2) `~/.claude/plugins/oh-my-claudecode/.usage-cache.json` (OMC cache) 3) Anthropic OAuth usage endpoint | Grant local folder access + click `Connect` |
| Gemini | Local `~/.gemini/oauth_creds.json` (CLI OAuth quota) or Gemini API key fallback | Grant local folder access for CLI OAuth, or save API key + enable |

## Installation (macOS)
Expand All @@ -61,6 +61,35 @@ open CreditClock.xcodeproj

In Xcode, run the `CreditClock` scheme.

### Hook Setup (Optional)

CreditClock includes a Claude Code hook that automatically caches your subscription usage data during Claude Code sessions. This is the fastest data source for Claude usage tracking — it writes to `~/.creditclock/usage-cache.json` so the app can read usage without needing OAuth or an active Claude Code session.

**Install:**

```bash
./scripts/install-claude-hook.sh
```

**Remove:**

```bash
./scripts/install-claude-hook.sh remove
```

**What the hook does:**

- Runs automatically during Claude Code sessions (`SessionStart` and `PostToolUse` events).
- Reads your OAuth token from macOS Keychain to call the Anthropic usage API.
- Writes the result to `~/.creditclock/usage-cache.json` for CreditClock to read.

**Files modified by the installer:**

- `~/.claude/settings.json` — adds hook entries under `hooks.PostToolUse` and `hooks.SessionStart`.
- `~/.creditclock/hooks/` — copies `fetch-usage.py` and `fetch-usage.sh` scripts.

> **Security note:** The hook reads your Claude OAuth token from the macOS Keychain to call the Anthropic API usage endpoint. Review the scripts in `scripts/` before installing.

## Background Behavior

- CreditClock runs as a menu bar agent (no Dock icon / no always-open main window).
Expand Down
135 changes: 89 additions & 46 deletions Shared/Providers/AnthropicProviderAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ struct AnthropicProviderAdapter: ServiceProvider {
let serviceId = "anthropic"

func fetchSnapshot() async throws -> ServiceSnapshot {
// Strategy 1: Read from OMC cache first to avoid frequent keychain prompts.
// Strategy 0: CreditClock's own hook cache (~/.creditclock/usage-cache.json).
// The dedicated Claude Code hook writes here during active sessions.
if let cached = readCreditClockCache() {
ProviderRecovery.log(.info, "[claude] load success source=creditClockHook")
return cached
}

// Strategy 1: Read from OMC cache (if oh-my-claudecode is installed).
if let cached = readOMCCache() {
ProviderRecovery.log(.info, "[claude] load success source=omcCache")
return cached
}
ProviderRecovery.log(.debug, "[claude] omc cache unavailable, trying oauth")
ProviderRecovery.log(.debug, "[claude] caches unavailable, trying oauth")

// Strategy 2: Direct OAuth API call with refresh-token fallback.
// Note: Anthropic may restrict third-party OAuth usage in the future.
// If the API becomes unavailable, this path degrades gracefully to
// CLI warmup + cache retry.
var oauthError: Error?
do {
let oauthResult = try await fetchViaOAuth()
Expand All @@ -23,56 +33,70 @@ struct AnthropicProviderAdapter: ServiceProvider {
oauthError = error
}

// Token can expire; for OAuth 401, run Claude CLI once to refresh auth artifacts,
// then retry cache/OAuth once.
if let oauthFailure = oauthError, ProviderRecovery.isUnauthorized(oauthFailure) {
guard ProviderRecovery.canRunCLIWarmup else {
// Recovery: CLI warmup to refresh auth artifacts and OMC cache, then retry.
if ProviderRecovery.canRunCLIWarmup {
let shouldWarmup: Bool = {
if let e = oauthError, ProviderRecovery.isUnauthorized(e) { return true }
if let e = oauthError, Self.isOAuthRestricted(e) { return true }
return true // Always attempt warmup as last resort
}()

if shouldWarmup {
ProviderRecovery.log(.default, "[claude] attempting CLI warmup for recovery")
let ranWarmup = await ProviderRecovery.runCLIWarmup(
command: "claude",
arguments: ["-p", "ping", "--output-format", "json"]
)
ProviderRecovery.log(
.default,
"[claude] oauth 401 but sandbox blocks CLI warmup. Re-authenticate via Terminal (`claude login`)."
.info,
"[claude] warmup \(ranWarmup ? "executed" : "skipped_or_failed"), retrying"
)
throw oauthFailure
}
ProviderRecovery.log(.default, "[claude] oauth 401 detected, attempting CLI warmup")
let ranWarmup = await ProviderRecovery.runCLIWarmup(
command: "claude",
arguments: [
"-p",
"ping",
"--output-format",
"json"
]
)
ProviderRecovery.log(
.info,
"[claude] warmup \(ranWarmup ? "executed" : "skipped_or_failed"), retrying cache/oauth"
)

if let cached = readOMCCache() {
ProviderRecovery.log(.info, "[claude] load success after warmup source=omcCache")
return cached
}
do {
let oauthResult = try await fetchViaOAuth()
ProviderRecovery.log(.info, "[claude] load success after warmup source=oauth")
return oauthResult
} catch {
ProviderRecovery.log(.default, "[claude] oauth retry failed: \(error.localizedDescription)")
oauthError = error

// After warmup, caches should be refreshed
if let cached = readCreditClockCache() ?? readOMCCache() {
ProviderRecovery.log(.info, "[claude] load success after warmup source=cache")
return cached
}

// Retry OAuth only if error was auth-related (not restriction)
if let e = oauthError, ProviderRecovery.isUnauthorized(e), !Self.isOAuthRestricted(e) {
do {
let oauthResult = try await fetchViaOAuth()
ProviderRecovery.log(.info, "[claude] load success after warmup source=oauth")
return oauthResult
} catch {
ProviderRecovery.log(.default, "[claude] oauth retry failed: \(error.localizedDescription)")
oauthError = error
}
}
}
}

// Final fallback: accept stale cache (up to 30 min) rather than nothing.
if let staleCached = readCreditClockCache(maxAgeMs: 1_800_000) ?? readOMCCache(maxAgeMs: 1_800_000) {
ProviderRecovery.log(.default, "[claude] using stale cache (up to 30 min old)")
return staleCached
}

if let oauthError {
ProviderRecovery.log(.error, "[claude] load failed with oauth error: \(oauthError.localizedDescription)")
ProviderRecovery.log(.error, "[claude] load failed: \(oauthError.localizedDescription)")
throw oauthError
}
ProviderRecovery.log(.error, "[claude] load failed: not authenticated")
throw ProviderError.notAuthenticated("Claude (no OAuth credentials or folder access)")
throw ProviderError.notAuthenticated("Claude (no OAuth credentials or OMC cache available)")
}

// MARK: - Strategy 0: CreditClock Hook Cache

private func readCreditClockCache(maxAgeMs: Double = 300_000) -> ServiceSnapshot? {
let cacheURL = SharedFallbackPath.url(fileName: "usage-cache.json")
guard let data = try? Data(contentsOf: cacheURL) else { return nil }
return snapshotFromCacheData(data, maxAgeMs: maxAgeMs)
}

// MARK: - Strategy 1: OMC Usage Cache

private func readOMCCache() -> ServiceSnapshot? {
private func readOMCCache(maxAgeMs: Double = 300_000) -> ServiceSnapshot? {
guard let data = ExternalDataAccess.shared.withDirectoryAccess(for: .claude, { claudeDir in
let cacheURL = claudeDir
.appendingPathComponent("plugins", isDirectory: true)
Expand All @@ -81,12 +105,19 @@ struct AnthropicProviderAdapter: ServiceProvider {
return try? Data(contentsOf: cacheURL)
}) else { return nil }

struct OMCCache: Decodable {
return snapshotFromCacheData(data, maxAgeMs: maxAgeMs)
}

// MARK: - Shared Usage Cache Parsing

/// Parses the usage-cache.json format shared by CreditClock hook and OMC plugin.
private func snapshotFromCacheData(_ data: Data, maxAgeMs: Double) -> ServiceSnapshot? {
struct CacheEnvelope: Decodable {
let timestamp: Double
let data: OMCUsage?
let data: CacheUsage?
let error: Bool?
}
struct OMCUsage: Decodable {
struct CacheUsage: Decodable {
let fiveHourPercent: Int?
let weeklyPercent: Int?
let fiveHourResetsAt: String?
Expand Down Expand Up @@ -118,13 +149,12 @@ struct AnthropicProviderAdapter: ServiceProvider {
}
}

guard let cache = try? JSONDecoder().decode(OMCCache.self, from: data),
guard let cache = try? JSONDecoder().decode(CacheEnvelope.self, from: data),
let usage = cache.data,
cache.error != true else { return nil }

// Cache older than 5 minutes is stale
let cacheAge = Date().timeIntervalSince1970 * 1000 - cache.timestamp
guard cacheAge < 300_000 else { return nil }
guard cacheAge < maxAgeMs else { return nil }

let fiveHour = usage.fiveHourPercent ?? 0
let weekly = usage.weeklyPercent ?? 0
Expand All @@ -142,7 +172,6 @@ struct AnthropicProviderAdapter: ServiceProvider {
weeklyReset = Date().addingTimeInterval(7 * 24 * 3600)
}

// Use the higher utilization as primary display
let primaryPercent = max(fiveHour, weekly)
let subscriptionDetail = normalizedPlanDetail(
subscriptionType: usage.subscriptionType,
Expand Down Expand Up @@ -247,6 +276,18 @@ struct AnthropicProviderAdapter: ServiceProvider {
return try JSONDecoder().decode(OAuthUsageResponse.self, from: data)
}

// MARK: - OAuth Restriction Detection

/// Detects if an error indicates Anthropic has restricted third-party OAuth access.
/// HTTP 403 from the usage endpoint likely means the restriction is active.
private static func isOAuthRestricted(_ error: Error) -> Bool {
guard let providerError = error as? ProviderError else { return false }
if case let .httpError(code, _) = providerError {
return code == 403
}
return false
}

// MARK: - Keychain OAuth Token

private func readClaudeCodeOAuthCredentials() -> ClaudeOAuthCredentials? {
Expand Down Expand Up @@ -417,6 +458,8 @@ struct AnthropicProviderAdapter: ServiceProvider {
_ = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
}

// MARK: - Helpers

private static func stringValue(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String, !value.isEmpty {
Expand Down
Loading