From a062e01f078a468571d0d7af571c950beb7ac394 Mon Sep 17 00:00:00 2001 From: woody Date: Sat, 21 Feb 2026 11:06:48 +0900 Subject: [PATCH 1/2] feat(providers): add hook-based usage cache as primary fetch strategy Add CreditClock's own hook cache (~/.creditclock/usage-cache.json) as Strategy 0, ahead of OMC cache and OAuth. This removes the hard dependency on oh-my-claudecode and prepares for potential Anthropic OAuth restrictions (HTTP 403 detection). - readCreditClockCache() as top-priority data source - Shared snapshotFromCacheData() parser for both cache formats - Stale cache fallback (up to 30 min) before failing - OAuth restriction detection (isOAuthRestricted) - UI text updated to reflect hook-based workflow Co-Authored-By: Claude Opus 4.6 --- CreditClockApp/ProviderSettingsView.swift | 19 +-- .../Providers/AnthropicProviderAdapter.swift | 135 ++++++++++++------ scripts/creditclock-usage-hook.py | 111 ++++++++++++++ scripts/creditclock-usage-hook.sh | 19 +++ scripts/install-claude-hook.sh | 124 ++++++++++++++++ 5 files changed, 349 insertions(+), 59 deletions(-) create mode 100755 scripts/creditclock-usage-hook.py create mode 100755 scripts/creditclock-usage-hook.sh create mode 100755 scripts/install-claude-hook.sh diff --git a/CreditClockApp/ProviderSettingsView.swift b/CreditClockApp/ProviderSettingsView.swift index d15b4f4..d93602f 100644 --- a/CreditClockApp/ProviderSettingsView.swift +++ b/CreditClockApp/ProviderSettingsView.swift @@ -98,7 +98,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) } @@ -515,29 +515,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: " ") } diff --git a/Shared/Providers/AnthropicProviderAdapter.swift b/Shared/Providers/AnthropicProviderAdapter.swift index 6c844cf..64ff25b 100644 --- a/Shared/Providers/AnthropicProviderAdapter.swift +++ b/Shared/Providers/AnthropicProviderAdapter.swift @@ -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() @@ -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) @@ -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? @@ -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 @@ -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, @@ -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? { @@ -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 { diff --git a/scripts/creditclock-usage-hook.py b/scripts/creditclock-usage-hook.py new file mode 100755 index 0000000..3372a67 --- /dev/null +++ b/scripts/creditclock-usage-hook.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""CreditClock Usage Cache Hook for Claude Code. + +Fetches Anthropic subscription usage data via OAuth and caches it locally +at ~/.creditclock/usage-cache.json for CreditClock to read. + +Installed as a Claude Code hook (PostToolUse / SessionStart). +""" + +import json +import os +import subprocess +import sys +import time +import urllib.request + +CACHE_DIR = os.path.expanduser("~/.creditclock") +CACHE_FILE = os.path.join(CACHE_DIR, "usage-cache.json") +CACHE_TTL_MS = 30_000 # 30 seconds +API_URL = "https://api.anthropic.com/api/oauth/usage" +API_TIMEOUT = 10 # seconds + + +def main(): + # Check cache freshness + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE) as f: + cache = json.load(f) + age_ms = time.time() * 1000 - cache.get("timestamp", 0) + if age_ms < CACHE_TTL_MS: + return # Cache is fresh + except (json.JSONDecodeError, OSError): + pass + + # Read OAuth token from macOS Keychain + token = read_access_token() + if not token: + return # No credentials, skip silently + + # Fetch usage data + try: + req = urllib.request.Request( + API_URL, + headers={ + "Authorization": f"Bearer {token}", + "anthropic-beta": "oauth-2025-04-20", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp: + data = json.loads(resp.read()) + except Exception: + write_cache(None, error=True) + return + + # Transform API response to cache format + five_hour = data.get("five_hour") or {} + seven_day = data.get("seven_day") or {} + + usage = { + "fiveHourPercent": int(five_hour.get("utilization", 0)), + "weeklyPercent": int(seven_day.get("utilization", 0)), + "fiveHourResetsAt": five_hour.get("resets_at"), + "weeklyResetsAt": seven_day.get("resets_at"), + "subscriptionType": data.get("subscription_type"), + "rateLimitTier": data.get("rate_limit_tier"), + } + write_cache(usage, error=False) + + +def read_access_token(): + """Read OAuth access token from macOS Keychain.""" + try: + result = subprocess.run( + ["security", "find-generic-password", + "-s", "Claude Code-credentials", "-w"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + creds = json.loads(result.stdout.strip()) + oauth = creds.get("claudeAiOauth", creds) + return oauth.get("accessToken") or oauth.get("access_token") + except Exception: + return None + + +def write_cache(data, error=False): + """Write usage data to CreditClock cache file.""" + os.makedirs(CACHE_DIR, exist_ok=True) + cache = { + "timestamp": int(time.time() * 1000), + "data": data, + "error": error, + "source": "creditclock", + } + try: + tmp = CACHE_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(cache, f) + os.replace(tmp, CACHE_FILE) # Atomic write + except OSError: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/creditclock-usage-hook.sh b/scripts/creditclock-usage-hook.sh new file mode 100755 index 0000000..ac084a4 --- /dev/null +++ b/scripts/creditclock-usage-hook.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# CreditClock Claude Code Hook - Bash Wrapper +# Fast cache-age check in pure bash; invokes Python only when cache is stale. +# This minimizes overhead since PostToolUse fires frequently. + +CACHE_FILE="$HOME/.creditclock/usage-cache.json" + +# Quick exit if cache is fresh (< 30 seconds old) +if [ -f "$CACHE_FILE" ]; then + file_mod=$(stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) + now=$(date +%s) + age=$(( now - file_mod )) + if [ "$age" -lt 30 ]; then + exit 0 + fi +fi + +# Cache is stale or missing — invoke the Python fetcher +exec python3 "$HOME/.creditclock/hooks/fetch-usage.py" & diff --git a/scripts/install-claude-hook.sh b/scripts/install-claude-hook.sh new file mode 100755 index 0000000..08e5611 --- /dev/null +++ b/scripts/install-claude-hook.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# CreditClock Claude Code Hook Installer +# +# Installs the CreditClock usage cache hook into Claude Code. +# This hook runs inside Claude Code sessions and periodically caches +# subscription usage data to ~/.creditclock/usage-cache.json. +# +# Usage: +# ./scripts/install-claude-hook.sh # Install +# ./scripts/install-claude-hook.sh remove # Uninstall + +set -euo pipefail + +HOOK_DIR="$HOME/.creditclock/hooks" +HOOK_PY="$HOOK_DIR/fetch-usage.py" +HOOK_SH="$HOOK_DIR/fetch-usage.sh" +SETTINGS_FILE="$HOME/.claude/settings.json" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HOOK_CMD="bash ~/.creditclock/hooks/fetch-usage.sh" + +install_hook() { + echo "Installing CreditClock Claude Code hook..." + + # Copy hook scripts + mkdir -p "$HOOK_DIR" + cp "$SCRIPT_DIR/creditclock-usage-hook.py" "$HOOK_PY" + cp "$SCRIPT_DIR/creditclock-usage-hook.sh" "$HOOK_SH" + chmod +x "$HOOK_PY" "$HOOK_SH" + + # Ensure Claude settings file exists + if [ ! -f "$SETTINGS_FILE" ]; then + mkdir -p "$(dirname "$SETTINGS_FILE")" + echo '{}' > "$SETTINGS_FILE" + fi + + # Add hooks to Claude Code settings + python3 << 'PYEOF' +import json +import os + +settings_path = os.path.expanduser("~/.claude/settings.json") +hook_cmd = "bash ~/.creditclock/hooks/fetch-usage.sh" + +with open(settings_path) as f: + settings = json.load(f) + +hooks = settings.setdefault("hooks", {}) + +# Add to PostToolUse (periodic refresh during sessions) +post_tool = hooks.setdefault("PostToolUse", []) +if not any(h.get("command") == hook_cmd for h in post_tool): + post_tool.append({"command": hook_cmd}) + +# Add to SessionStart (immediate data on session start) +session_start = hooks.setdefault("SessionStart", []) +if not any(h.get("command") == hook_cmd for h in session_start): + session_start.append({"command": hook_cmd}) + +with open(settings_path, "w") as f: + json.dump(settings, f, indent=2) + +print("Hook configuration added to ~/.claude/settings.json") +PYEOF + + echo "" + echo "CreditClock hook installed successfully!" + echo " Hook scripts: $HOOK_DIR/" + echo " Cache output: ~/.creditclock/usage-cache.json" + echo "" + echo "The hook will automatically run during Claude Code sessions" + echo "and cache your subscription usage data for CreditClock to read." +} + +remove_hook() { + echo "Removing CreditClock Claude Code hook..." + + # Remove hook scripts + rm -f "$HOOK_PY" "$HOOK_SH" + rmdir "$HOOK_DIR" 2>/dev/null || true + + # Remove from Claude Code settings + if [ -f "$SETTINGS_FILE" ]; then + python3 << 'PYEOF' +import json +import os + +settings_path = os.path.expanduser("~/.claude/settings.json") +hook_cmd = "bash ~/.creditclock/hooks/fetch-usage.sh" + +with open(settings_path) as f: + settings = json.load(f) + +hooks = settings.get("hooks", {}) + +for event in ["PostToolUse", "SessionStart"]: + if event in hooks: + hooks[event] = [h for h in hooks[event] if h.get("command") != hook_cmd] + if not hooks[event]: + del hooks[event] + +if not hooks: + del settings["hooks"] + +with open(settings_path, "w") as f: + json.dump(settings, f, indent=2) + +print("Hook configuration removed from ~/.claude/settings.json") +PYEOF + fi + + # Remove cache file + rm -f "$HOME/.creditclock/usage-cache.json" + + echo "CreditClock hook removed successfully." +} + +case "${1:-}" in + remove|uninstall) + remove_hook + ;; + *) + install_hook + ;; +esac From 44145e3a79899534eb054ae2e00192323af00b7b Mon Sep 17 00:00:00 2001 From: woody Date: Mon, 23 Feb 2026 09:04:41 +0900 Subject: [PATCH 2/2] feat(settings): show hook install guidance and update docs Add informational banner in ProviderSettingsView when the Claude Code usage-cache hook is not installed, guiding users to run the installer script. Update both README files with hook setup instructions and revised data source priority (hook > OMC cache > OAuth). Co-Authored-By: Claude Opus 4.6 --- CreditClockApp/ProviderSettingsView.swift | 19 ++++++++++++++ README.ko.md | 31 ++++++++++++++++++++++- README.md | 31 ++++++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CreditClockApp/ProviderSettingsView.swift b/CreditClockApp/ProviderSettingsView.swift index d93602f..ef3b43c 100644 --- a/CreditClockApp/ProviderSettingsView.swift +++ b/CreditClockApp/ProviderSettingsView.swift @@ -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") { @@ -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() } diff --git a/README.ko.md b/README.ko.md index 0567aee..60107d7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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) @@ -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 아이콘/상시 메인 창 없음). diff --git a/README.md b/README.md index 858afee..1329f90 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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).