复用 Claude CLI Keychain 凭证零配置登录 + Strategy 协议骨架

May 12, 2026 · View on GitHub

1. 背景与目标

调研 §1.5 / §2.4 指出 CodexBar 的关键差异化能力之一是复用 Claude CLI 的 OAuth 凭证,让已装 Claude Code 的用户零配置登录。我们当前 UsageService 仅有 builtin OAuth 单路径,新用户必须走 PKCE 浏览器流程。

事故警示(来自 v0.1.1 设计阶段):作者在 spec 调研期间执行了 security find-generic-password -s 'Claude Code-credentials' -w | sed 's/^./X/' 命令试图脱敏读取 Keychain 项;但 sed 's/^./X/' 仅替换每行第一字符,整行 token 主体仍打印到对话 transcript 中,造成真实 token 泄漏。立即建议用户 claude logout && claude login 轮换。此事件促使 SC7 永久写入"禁止 print/log credentials"约束,是本 spec 最高优先级的安全规则。

本 spec 引入:

  • ClaudeUsageStrategy protocol 骨架(为后续 v0.1.2 本地 cost / v0.1.3 多账号 / 未来扩展数据源 spec 复用)
  • ClaudeCLICredentialsStrategy 单一实现:从 macOS Keychain 读 Claude Code-credentials
  • UsageService 启动时一次性 bootstrap:若本地 credentials.json 不存在则尝试 strategy

不在范围

  • 不重构现有 OAuth / refresh / polling 逻辑(仍是默认主路径;Strategy 仅在 bootstrap 用一次)
  • 不引入 strategy chain fallback(OAuth 失败不自动 retry Keychain;仅 bootstrap)
  • 不读 ~/.claude/.credentials.json 文件路径(现代 Claude CLI 已用 Keychain;本地实测 ~/.claude/ 无该文件;文件 fallback 留 v0.2.x)
  • 不引入 ADR(strategy 协议是单文件骨架,未来 multi-source 时再开 ADR)
  • 不动 SetupView / CodeEntryView / Settings UI
  • a11y 不涉及

2. 决策摘要

决策点选择原因
协议形态protocol ClaudeUsageStrategy { func loadCredentials() async throws -> StoredCredentials? } 单方法当前只需"提供 credentials"语义;avoid YAGNI 多方法
触发时机UsageService 启动 task 内、credentials.json 不存在时一次性 bootstrap不与现有 OAuth 流程冲突;最小侵入
Keychain 读法macOS Security framework SecItemCopyMatching 直接读 generic password标准 API;无需 Security CLI 子进程;无 prompt(已 ACL 信任的 app 可直接读,否则返回错误码)
失败行为任何 SecItem 错误 / JSON 解析错误 → return nil;UsageService 走原 sign-in 路径静默降级 = 与未装 Claude CLI 用户体验一致
安全约束永久禁止 print/log credentialsv0.1.1 设计阶段事故(见 §1);SC7 + SC_AUTO_NO_PRINT_TOKENS grep 守护
单位转换Keychain JSON expiresAt 是毫秒(13 位)→ /1000 转 Date实测 Keychain 内容(保留单测覆盖);与 v0.0.6 已有 token refresh 逻辑兼容
测试策略mock JSON 字符串(不含真实 token 前缀)+ pure logic 单测不引入 Keychain 实测依赖;CI 可重复
ADR暂不开strategy 是骨架;v0.1.2 / v0.1.3 多源真正落地时再开 ADR
Logger 选择错误路径仅 NSLog 简短文本 "credentials parse failed: ",禁止带 raw value与 SC7 对齐

3. 设计

3.1 数据流

.app 启动 → UsageBarApp.task
              ├─ historyService.loadHistory()
              ├─ service.bootstrapFromCLIIfNeeded()  // 新增
              │     ├─ credentialsStore.load() 已有 → 跳过
              │     └─ 否则 ClaudeCLICredentialsStrategy.loadCredentials() async
              │           成功 → credentialsStore.save() + service.adoptCredentials()
              │           nil/error → 静默
              └─ service.startPolling()

3.2 ClaudeUsageStrategy.swift

import Foundation

/// 多数据源抽象骨架。当前仅 ClaudeCLICredentialsStrategy 一个实现;
/// v0.1.2 LocalCostScanStrategy / v0.1.3 MultiAccountStrategy 已加入;
/// 未来若需要新数据源 spec 在此扩展。
protocol ClaudeUsageStrategy {
    /// 从该 strategy 提供凭证。返回 nil 表示该 strategy 无凭证可提供(静默降级);
    /// 抛出 error 表示明确异常需上层 log(但**不得带 raw credential 值**)。
    func loadCredentials() async throws -> StoredCredentials?
}

3.3 ClaudeCLICredentialsStrategy.swift

import Foundation
import Security

struct ClaudeCLICredentialsStrategy: ClaudeUsageStrategy {
    static let serviceName = "Claude Code-credentials"

    /// Keychain JSON 顶层 schema (实测自 macOS 14 Claude CLI):
    /// { "claudeAiOauth": { "accessToken": String, "refreshToken": String?,
    ///                       "expiresAt": Int (ms timestamp), "scopes": [String], ... },
    ///   "mcpOAuth": { ... } }  // mcpOAuth 不读
    /// `internal` 而非 `private` — 让 @testable import 单测能直接 decode 验证 schema
    /// 而无需 Keychain 实测。
    struct KeychainPayload: Decodable {
        let claudeAiOauth: ClaudeOauth
        struct ClaudeOauth: Decodable {
            let accessToken: String
            let refreshToken: String?
            let expiresAt: Int64?  // ms timestamp
            let scopes: [String]?
        }
    }

    /// SC7 安全约束:CustomStringConvertible 仅输出 case 名,不带 OSStatus
    /// 数值(避免日志聚合工具二次解析数值码暴露异常类型分布)
    enum LoadError: Error, CustomStringConvertible {
        case keychainQueryFailed
        case payloadDecodeFailed

        var description: String {
            switch self {
            case .keychainQueryFailed: return "keychainQueryFailed"
            case .payloadDecodeFailed: return "payloadDecodeFailed"
            }
        }
    }

    func loadCredentials() async throws -> StoredCredentials? {
        // G3 B1 修订:SecItemCopyMatching 是同步 blocking C API;用 Task.detached
        // 把它挪到后台线程,避免主线程阻塞(首次 ACL 弹窗时尤其重要)
        let queryResult: (status: OSStatus, item: AnyObject?) = await Task.detached {
            let query: [CFString: Any] = [
                kSecClass: kSecClassGenericPassword,
                kSecAttrService: Self.serviceName,
                kSecAttrAccount: NSUserName(),  // G2 E 修订:补 account 防 multi-account 顺序歧义
                kSecReturnData: true,
                kSecMatchLimit: kSecMatchLimitOne
            ]
            var item: AnyObject?
            let status = SecItemCopyMatching(query as CFDictionary, &item)
            return (status, item)
        }.value

        switch queryResult.status {
        case errSecSuccess:
            break
        case errSecItemNotFound,         // -25300 未装 Claude CLI 或无该 account 项
             errSecAuthFailed,            // -25293 ACL 验证失败
             errSecInteractionNotAllowed, // -25308 后台进程无法弹 ACL prompt
             errSecUserCanceled:          // -128 用户在 ACL prompt 上点取消
            return nil  // G2 F 修订:四种"权限/不存在"OSStatus 都静默降级
        default:
            throw LoadError.keychainQueryFailed
        }
        guard let data = queryResult.item as? Data else { return nil }
        guard let payload = try? JSONDecoder().decode(KeychainPayload.self, from: data) else {
            throw LoadError.payloadDecodeFailed
        }
        let oauth = payload.claudeAiOauth
        let expiry: Date? = oauth.expiresAt.map { Date(timeIntervalSince1970: TimeInterval(\$0) / 1000.0) }
        return StoredCredentials(
            accessToken: oauth.accessToken,
            refreshToken: oauth.refreshToken,
            expiresAt: expiry,
            scopes: oauth.scopes ?? []
        )
    }
}

3.4 UsageService 改动

新增 func bootstrapFromCLIIfNeeded() async —— 在现有 startPolling() 之前调用:

@MainActor
func bootstrapFromCLIIfNeeded() async {
    if credentialsStore.load(defaultScopes: defaultScopes) != nil { return }
    let strategy = ClaudeCLICredentialsStrategy()
    do {
        guard let creds = try await strategy.loadCredentials() else { return }
        try credentialsStore.save(creds)
        adoptCredentials(creds)  // 新增 helper:写入 self.credentials + isAuthenticated 切为 true
    } catch {
        // SC7 安全约束:仅记录 error 类型,不带 raw value
        NSLog("[usage-bar] credentials bootstrap from CLI failed: \(type(of: error))")
    }
}

UsageBarApp.task 内调用 await service.bootstrapFromCLIIfNeeded()service.startPolling() 前。

3.5 测试

ClaudeCLICredentialsStrategyTests:用一个 helper 把 mock JSON 字符串 → 调 KeychainPayload decode(真实调 SecItemCopyMatching)。

// 测试用 mock JSON(注意:accessToken 用 'mock-' 前缀,绝不用 'sk-ant-' 真实前缀)
private let validJSON = """
{"claudeAiOauth":{"accessToken":"mock-access-1","refreshToken":"mock-refresh-1",
"expiresAt":1778520574000,"scopes":["user:profile","user:inference"]}}
"""

case:

  • testValidPayloadDecodes: validJSON → 解码成功,accessToken="mock-access-1"
  • testMissingClaudeOauth: {}(无 claudeAiOauth)→ decode 失败
  • testMissingAccessToken: {"claudeAiOauth":{"refreshToken":"x"}} → decode 失败
  • testExpiredCredentials: validJSON 但 expiresAt 远过去 → 解码成功(失效判定由上层 isExpired() 处理;strategy 不过滤)
  • testNilExpiresAt: {"claudeAiOauth":{"accessToken":"mock"}} → expiresAt 为 nil
  • testMillisecondConversion: expiresAt=1778520574000 (ms) → Date(timeIntervalSince1970: 1778520574.0)

测试通过 @testable import UsageBar 直接 decode ClaudeCLICredentialsStrategy.KeychainPayload(internal 可见)验证 schema;不调用 SecItemCopyMatching,纯 JSON → KeychainPayload → 转 StoredCredentials 的字段映射。生产路径走 loadCredentials() 完整流程(含 Task.detached + Keychain)。

SC7 约束:单测禁止 XCTAssertEqual(creds.accessToken, "mock-access-1") 字面比较 —— 改用 XCTAssertTrue(creds.accessToken.hasPrefix("mock-"))XCTAssertEqual(creds.accessToken.count, 13) 等 prefix/count 断言;失败时 framework 不会打印完整 raw value 至 test log。

3.6 Implementation plan(G3 对象)

Step P0 — spec + version + 索引(Commit A,仅文档)

  • 升 v0.1.1 placeholder→planned;删 guardrail
  • specs/README.md / versions/README.md 索引同步
  • Success: linkcheck ✅;frontmatter ✅;grep -A1 '^status:' docs/versions/v0.1.1-*.md 输出 status: planned(G3 R3 修订:硬证据命令)
  • 覆盖 SC: 无

Step P1 — Strategy 协议 + Strategy 实现 + 单测(Commit B)

  • 新增 ClaudeUsageStrategy.swift(protocol)
  • 新增 ClaudeCLICredentialsStrategy.swift(impl + KeychainPayload internal struct + LoadError CustomStringConvertible + Task.detached)
  • 新增 ClaudeCLICredentialsStrategyTests.swift(≥4 case,用 @testable import 直接 decode KeychainPayload;mock JSON 用 'mock-' 前缀;断言用 hasPrefix/count 不字面比较 token 字段)
  • Success:
    • swift test 全集绿;swift build -c release 绿
    • grep -nrI 'sk-ant-' macos/ docs/ 无匹配(SC7 SC_AUTO_NO_REAL_TOKEN_PREFIX 守护)
    • SC_AUTO_NO_PRINT_TOKENS grep 无匹配(守护 print/NSLog/Logger × token 字段)
  • 刻意单 commit 说明(G3 R1 noted-only):与已沉淀 v0.0.x B 经验略偏离 — protocol 单方法 + impl + 单测 都仅覆盖一个 strategy,强耦合不拆;后续 v0.1.2/3 加 strategy 时各自独立 commit
  • 覆盖 SC: SC1, SC2, SC4, SC5, SC6, SC7(前置)

Step P2 — UsageService bootstrap + UsageBarApp 接入(Commit C)

  • UsageService 加 bootstrapFromCLIIfNeeded() + 私有 adoptCredentials(_:)
  • UsageBarApp.task 在 startPolling 前 await bootstrapFromCLIIfNeeded
  • Success:
    • swift build -c release && swift test 全绿;启动 .app 进程不崩
    • git diff --stat HEAD~1..HEAD 白名单:仅触 macos/Sources/UsageBar/UsageService.swift + macos/Sources/UsageBar/UsageBarApp.swift 两文件(G3 R2 修订:SC8 反向断言落到可观测命令)
    • SC_AUTO_NO_PRINT_TOKENS / SC_AUTO_NO_REAL_TOKEN_PREFIX 仍无匹配
  • 覆盖 SC: SC3, SC8, SC9, SC10

G5 gate — 独立 reviewer code-review 加 security review focus

  • (a) SC7 安全约束:grep 检查无 print/log credentials;错误路径只 log type 不 log raw
  • (b) Keychain 错误处理:errSecItemNotFound 静默 / 其他错误日志 type
  • (c) JSON decode 边界:缺字段 / 非法值
  • (d) 单位转换 ms → s 正确
  • (e) UsageService bootstrap 不破坏现有 OAuth / refresh 路径
  • (f) commit B/C 独立可 revert

Step P3 — G6 收尾(Commit D)

  • spec.status accepted → implemented;reviews append G5 + G6
  • Verification log 全 [x];索引同步;CHANGELOG entry;version → in-progress
  • Success(G3 R2 修订):
    • grep -c '^ - gate:' docs/superpowers/specs/2026-05-11-claude-cli-credentials.md 输出 4(G2 / G3 / G5 / G6 verdict)
    • grep -c '^## \[v0.1.1\]' CHANGELOG.md 输出 1
  • 覆盖 SC: SC11, SC12

4. 现有文件迁移动作

动作文件备注
🆕macos/Sources/UsageBar/ClaudeUsageStrategy.swiftprotocol 骨架
🆕macos/Sources/UsageBar/ClaudeCLICredentialsStrategy.swift实现 + Keychain 读
🆕macos/Tests/UsageBarTests/ClaudeCLICredentialsStrategyTests.swiftmock JSON 测 ≥4 case
🔧macos/Sources/UsageBar/UsageService.swift加 bootstrapFromCLIIfNeeded() + adoptCredentials helper
🔧macos/Sources/UsageBar/UsageBarApp.swift.task 加 await service.bootstrapFromCLIIfNeeded()
🔧docs/versions/v0.1.1-claude-cli-credentials.md / 索引 / CHANGELOG标准收尾
✅ 不动OAuth / refresh / SetupView / CodeEntry / Settings / 数据层 / Notifications / hero/menubar/pace 等仅在 startup 早期插入 bootstrap

5. 风险 / Open questions

  1. Keychain ACL:用户首次启动我们的 .app 读 Claude Code-credentials 时,macOS 可能弹出"允许 UsageBar 访问 Claude Code-credentials"提示。接受:用户主动选择允许 / 拒绝;拒绝则降级 sign-in 与未装 Claude CLI 同款。可在后续 user-guide 文档说明此提示。
  2. Keychain JSON schema 漂移:实测的 schema 是当前 Claude CLI 版本快照;未来 Claude CLI 改字段名/结构会导致 decode 失败 → 静默降级。对策:失败仅 log type,不影响其他流程;CodexBar 同款 risk(调研 §8.3)。
  3. 同时持有两份 token:本机已 sign-in 主 app + 装了 Claude CLI 时,bootstrap 检查 credentialsStore.load() != nil 后跳过,不会覆盖;token refresh 由现有 UsageService 路径独立处理。
  4. ~/.claude/.credentials.json 文件路径不读:现代 Claude CLI 已用 Keychain,文件不存在;本地实测 ~/.claude/ 无 .credentials.json。文件 fallback 留 v0.2.x(如有用户报告需要)。
  5. 多用户 / 多 Claude 账号:v0.1.3 multi-account spec 处理;本 spec 假设 Keychain 只有一个 Claude Code-credentials 项。
  6. 测试 mock JSON 前缀:用 'mock-' 前缀绝不用 'sk-ant-';SC_AUTO_NO_PRINT_TOKENS grep + manual check grep -nrI 'sk-ant-' 双重守护;commit / PR / spec / CHANGELOG 同款约束。
  7. 设计阶段事故警示(永久):v0.1.1 调研时作者命令 security find-generic-password -s 'Claude Code-credentials' -w | sed 's/^./X/' 试图脱敏失败,把真实 token 打印到对话 transcript;用户立即 claude logout && claude login 轮换。未来调试 Keychain 永远用 mock JSON 或本地脚本,绝不在 AI 对话/CI 输出中读真实凭证内容。SC7 自动化守护 + manual check 双重防护。
  8. a11y / 国际化:本 spec 不引入 UI;无需。
  9. Claude CLI 与 usage-bar refresh client_id 是否同源(G2 advisory L):bootstrap 来的 token 复用现有 credentialsStore.save()StoredCredentials,refresh 路径走 UsageService 现有 OAuth refresh endpoint。但 Claude CLI 与 usage-bar 是不同 app,OAuth 注册的 client_id 可能不同(实际未确认)。若 client_id 不同,refresh request 会被 Anthropic 拒绝;用户会看到 token expired 后必须手动 sign-in 重走 PKCE。对策:实施后 manual 验证 — bootstrap 触发后等 token 临近 expiry,观察 UsageService 自动 refresh 行为;如失败则 §5 升 BLOCKING 加 fallback(直接走 sign-in 而非尝试 refresh CLI 来的 token)。

6. 后续工作(不在本 spec 范围)

  • LocalCostScanStrategy(解析 ~/.claude/projects/**/*.jsonl 算本地 cost) → v0.1.2
  • MultiAccountStrategy(多 token / 账号切换) → v0.1.3
  • 未来扩展数据源(如有需要)通过 ClaudeUsageStrategy 协议添加
  • 上述多源真正落地时统一开 ADR 总结 strategy chain 设计

7. 引用

Verification log

G6 验收依据。每条 SC 完成时勾选并填 evidence。

  • SC1 — evidence: commit 30edc7f 新增 ClaudeUsageStrategy.swift 单方法 protocol
  • SC2 — evidence: commit 30edc7f 新增 ClaudeCLICredentialsStrategy.swift(kSecAttrAccount=NSUserName() + Task.detached 主线程不阻塞)
  • SC3 — evidence: commit 3e3d38c UsageService.bootstrapFromCLIIfNeeded()(loadCredentials nil 时尝试 strategy)+ UsageBarApp.task await 串入;G5 修订加 @MainActor 显式标注
  • SC4 — evidence: commit 30edc7f ClaudeCLICredentialsStrategyTests 6 case(valid / missing oauth / missing accessToken / nil 字段 / ms→s 转换 / LoadError 脱敏);mock- 前缀 + hasPrefix/count/nil 断言
  • SC5 — evidence: testMillisecondToDateConversion 显式覆盖 1778520574000ms → 1778520574.0s(accuracy 0.001)
  • SC6 — evidence: ClaudeCLICredentialsStrategy.swift switch 把 errSecItemNotFound / errSecAuthFailed / errSecInteractionNotAllowed / errSecUserCanceled 都映射为 return nil
  • SC7 — evidence: LoadError CustomStringConvertible 仅输出 case 名(testLoadErrorDescriptionDoesNotLeakRawValue 验证);mock- 前缀 token;hasPrefix/count 断言;SC_AUTO_NO_REAL_TOKEN_PREFIX sk-ant-(oat|ort|api)[0-9] 全仓 0 匹配;SC_AUTO_NO_PRINT_TOKENS 修订后 0 匹配
  • SC8 — evidence: git diff 7fb66f5..HEAD 仅触应改文件:spec / version / 索引 / 3 新文件(ClaudeUsageStrategy.swift / ClaudeCLICredentialsStrategy.swift / Tests)+ UsageService.swift(仅加新方法)+ UsageBarApp.swift(仅 .task 调整);OAuth/refresh/polling/SetupView/CodeEntry/Settings/Notifications/数据层全无改动 ✅
  • SC9 — evidence: cd macos && swift build -c release 输出 Build complete!
  • SC10 — evidence: cd macos && swift test Executed 84 tests, with 0 failures
  • SC11 — evidence: 5 个中文 commit 均含 spec id(7fb66f5 / 30edc7f / 3e3d38c / G5 fix / 本 commit);spec.reviews 含 G2/G3/G5/G6 共 4 条 verdict
  • SC12 — evidence: version v0.1.1 frontmatter status placeholder→planned(7fb66f5)→in-progress(本 commit);CHANGELOG.md append v0.1.1 entry(本 commit)