@sriinnu/runic 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +40 -29
  3. package/Scripts/package_app.sh +2 -2
  4. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +10 -1
  5. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +30 -2
  6. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +2 -5
  7. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +9 -33
  8. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +9 -33
  9. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +9 -33
  10. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +9 -33
  11. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +9 -33
  12. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +9 -33
  13. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +9 -33
  14. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +9 -33
  15. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +9 -33
  16. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +9 -33
  17. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +9 -33
  18. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +9 -33
  19. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +9 -33
  20. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +9 -33
  21. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +9 -33
  22. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +9 -33
  23. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +9 -33
  24. package/Sources/Runic/Core/Stores/SettingsStore.swift +10 -1
  25. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +9 -33
  26. package/Sources/Runic/Core/Stores/UsageStore.swift +50 -9
  27. package/Sources/Runic/Core/Stores/XAITokenStore.swift +9 -33
  28. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +9 -34
  29. package/Sources/Runic/Resources/provider-context-windows.json +20 -0
  30. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +9 -16
  31. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +192 -55
  32. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +12 -65
  33. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +71 -1
  34. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +11 -0
  35. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
  36. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +2 -0
  37. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
  38. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
  39. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
  40. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1 -0
  41. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +1 -0
  42. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +1 -4
  43. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +2 -6
  44. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +1 -3
  45. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +3 -9
  46. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +117 -19
  47. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +49 -3
  48. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +158 -0
  49. package/appcast.xml +39 -0
  50. package/package.json +1 -1
  51. package/version.env +2 -2
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Srinivas Pendela
3
+ Copyright (c) 2025-2026 Srinivas Pendela
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -24,73 +24,84 @@ Runic sits in your menubar and shows how much of your AI subscription you've use
24
24
 
25
25
  | | | |
26
26
  |---|---|---|
27
- | Claude | Codex | Cursor |
28
- | Gemini | Copilot | z.ai |
29
- | OpenRouter | Groq | DeepSeek |
30
- | Fireworks | Mistral | Perplexity |
31
- | Kimi | Together | Cohere |
32
- | xAI | Cerebras | SambaNova |
27
+ | Claude (1M ctx) | Codex (400K ctx) | Cursor (128K ctx) |
28
+ | Gemini (1M ctx) | Copilot (128K ctx) | z.ai (205K ctx) |
29
+ | OpenRouter | Groq (128K ctx) | DeepSeek (64K ctx) |
30
+ | Fireworks (128K ctx) | Mistral (128K ctx) | Perplexity (128K ctx) |
31
+ | Kimi (128K ctx) | Together (128K ctx) | Cohere (128K ctx) |
32
+ | xAI (128K ctx) | Cerebras (128K ctx) | SambaNova (128K ctx) |
33
33
  | Azure OpenAI | Bedrock | Vertex AI |
34
- | Qwen | MiniMax | Auggie |
34
+ | Qwen (128K ctx) | MiniMax | Auggie |
35
35
  | Antigravity | Factory (Droid) | |
36
36
 
37
+ Context windows are configurable via `Resources/provider-context-windows.json`.
38
+
37
39
  ## Features
38
40
 
39
- **Menu dropdown**
40
- - Provider tab bar with brand icons
41
- - Hero stat with today's token count and cost
42
- - Inline line chart (1h / 6h / 1d / 7d / 30d)
43
- - Glassmorphism stat cards with sparklines
44
- - Usage progress bars with sheen animation
41
+ **Overview dashboard**
42
+ - All providers at a glance with brand icons and progress bars
43
+ - Activity ring showing average usage across providers
44
+ - Context window, reset countdown, and usage window per provider
45
+ - Combined 7-day stacked bar chart
46
+
47
+ **Per-provider view**
48
+ - Hero stat with provider icon, today's token count and cost
49
+ - Inline line chart with 1h / 6h / 1d / 7d / 30d range picker
50
+ - Glassmorphism stat cards (Peak Hour, This Week) with sparklines
51
+ - Usage progress bars with sheen animation and glow effects
45
52
  - Live "Updated Xs ago" timestamp
46
53
 
47
- **Charts**
48
- - Usage timeline (area + line)
49
- - Today by hour (24-bar)
50
- - Last 7 days (weekly bars)
54
+ **Charts** (submenus)
55
+ - Usage timeline (area + line, Catmull-Rom interpolated)
56
+ - Today by hour (24-bar chart with peak highlight)
57
+ - Last 7 days (weekly bar chart)
51
58
  - Subscription utilization (Daily / Weekly / Monthly)
52
- - Usage window comparison (session vs weekly)
53
- - Model breakdown (donut)
54
- - Project breakdown (bar)
59
+ - Usage window comparison (dual-line session vs weekly)
60
+ - Model breakdown (donut chart)
61
+ - Project breakdown (horizontal bar chart)
55
62
 
56
63
  **Analytics**
57
- - Token usage (input, output, cache)
64
+ - Token usage tracking (input, output, cache)
58
65
  - Cost estimation with per-model pricing
59
- - Spend forecasting and budget alerts
66
+ - Spend forecasting with budget breach detection
60
67
  - Project and model attribution
68
+ - Anomaly detection
61
69
 
62
70
  **Export & Notifications**
63
71
  - Export as CSV or JSON
64
72
  - Budget breach alerts via macOS notifications
65
- - macOS widgets
73
+ - macOS widgets (usage, history, compact, switcher)
66
74
  - CLI tool (`RunicCLI`)
67
75
 
68
76
  **Design**
69
77
  - Dark / Light / System theme
70
78
  - Liquid UI with glass materials and animated progress bars
71
- - SF Rounded typography
79
+ - Staggered entrance animations and glass shimmer effects
80
+ - SF Rounded typography with design tokens
72
81
  - VoiceOver accessible
73
82
  - Sparkle auto-updates
74
83
 
75
84
  ## Install
76
85
 
86
+ **Download** the latest release from [GitHub Releases](https://github.com/sriinnu/Runic/releases/latest), unzip, and drag `Runic.app` to Applications. Signed and notarized — no Gatekeeper warnings.
87
+
88
+ **Or build from source:**
89
+
77
90
  ```bash
78
91
  git clone https://github.com/sriinnu/Runic.git
79
92
  cd Runic
80
93
  ./Scripts/compile_and_run.sh
81
94
  ```
82
95
 
83
- Builds the app, signs it, installs to `/Applications`, and launches. The infinity icon appears in your menubar.
84
-
85
96
  ## Configure
86
97
 
87
98
  Open **Preferences** from the menubar. Each provider has its own settings:
88
99
 
89
- - **API-based** (Groq, Mistral, z.ai, etc.): Paste your API key
90
- - **CLI-based** (Claude, Codex): Detected automatically
100
+ - **API-based** (Groq, Mistral, z.ai, etc.): Paste your API key — provider auto-enables
101
+ - **CLI-based** (Claude, Codex): Detected automatically from local CLI
91
102
  - **Cloud** (Bedrock, Vertex AI): Set environment variables
92
103
 
93
- All tokens stored in macOS Keychain — no password prompts.
104
+ All tokens stored in macOS Keychain with no password prompts.
94
105
 
95
106
  ## Privacy
96
107
 
@@ -218,9 +218,9 @@ cat > "$APP/Contents/Info.plist" <<PLIST
218
218
  <key>LSMinimumSystemVersion</key><string>14.0</string>
219
219
  <key>LSUIElement</key><true/>
220
220
  <key>CFBundleIconFile</key><string>Icon</string>
221
- <key>NSHumanReadableCopyright</key><string>© 2026 Srinivas Pendela (hello@srinivas.dev). MIT License.</string>
221
+ <key>NSHumanReadableCopyright</key><string>© 2025-2026 Srinivas Pendela. MIT License.</string>
222
222
  <key>SUFeedURL</key><string>${FEED_URL}</string>
223
- <key>SUPublicEDKey</key><string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
223
+ <key>SUPublicEDKey</key><string>1Ay3r9lgXQtyOjzVSCjlT5dvQuRlJghNKa8Goqvhfz4=</string>
224
224
  <key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
225
225
  <key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
226
226
  <key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
@@ -6,7 +6,16 @@ extension StatusItemController {
6
6
  // MARK: - Actions reachable from menus
7
7
 
8
8
  @objc func refreshNow() {
9
- Task { await self.store.refresh(trigger: .manual, forceTokenUsage: true) }
9
+ let provider = self.selectedMenuProvider
10
+ if let provider {
11
+ // Single provider selected — only refresh that one.
12
+ Task {
13
+ await self.store.refreshSingleProvider(provider)
14
+ }
15
+ } else {
16
+ // Overview — refresh all.
17
+ Task { await self.store.refresh(trigger: .manual, forceTokenUsage: true) }
18
+ }
10
19
  }
11
20
 
12
21
  @objc func installUpdate() {
@@ -432,6 +432,13 @@ extension StatusItemController {
432
432
  totalToday += todayTokens
433
433
 
434
434
  let resetDesc = snapshot?.primary.resetDescription
435
+ let windowLabel = snapshot?.primary.label?.trimmingCharacters(in: .whitespacesAndNewlines)
436
+ let topModel = self.store.ledgerTopModel(for: provider)
437
+ let topModelContext: String? = if let model = topModel?.model {
438
+ UsageFormatter.modelContextLabel(for: model)
439
+ } else {
440
+ Self.defaultContextLabel(for: provider)
441
+ }
435
442
 
436
443
  summaries.append(OverviewMenuView.ProviderSummary(
437
444
  id: provider.rawValue,
@@ -441,7 +448,9 @@ extension StatusItemController {
441
448
  usedPercent: usedPercent,
442
449
  todayTokens: todayTokens,
443
450
  brandColor: brandColor,
444
- resetDescription: resetDesc))
451
+ resetDescription: resetDesc,
452
+ windowLabel: windowLabel,
453
+ topModelContext: topModelContext))
445
454
 
446
455
  // Chart data — last 7 days
447
456
  let dailySummaries = self.store.ledgerAllDailySummary(for: provider)
@@ -450,7 +459,8 @@ extension StatusItemController {
450
459
  id: "\(provider.rawValue)-\(summary.dayKey)",
451
460
  date: summary.dayStart,
452
461
  tokens: summary.totals.totalTokens,
453
- provider: meta.displayName))
462
+ provider: meta.displayName,
463
+ color: brandColor))
454
464
  }
455
465
  }
456
466
 
@@ -461,6 +471,7 @@ extension StatusItemController {
461
471
  summaries: activeSummaries.isEmpty ? summaries : activeSummaries,
462
472
  chartPoints: chartPoints,
463
473
  totalTodayTokens: totalToday,
474
+ totalProviders: UsageProvider.allCases.count,
464
475
  width: width)
465
476
  }
466
477
 
@@ -477,6 +488,23 @@ extension StatusItemController {
477
488
  return abbreviations[name] ?? String(name.prefix(6))
478
489
  }
479
490
 
491
+ /// Default flagship model context window per provider, loaded from
492
+ /// `Resources/provider-context-windows.json`. Update the JSON to add/change
493
+ /// context windows without recompiling.
494
+ private static let defaultContextWindows: [String: Int] = {
495
+ struct Entry: Decodable { let contextK: Int }
496
+ guard let url = Bundle.main.url(forResource: "provider-context-windows", withExtension: "json"),
497
+ let data = try? Data(contentsOf: url),
498
+ let dict = try? JSONDecoder().decode([String: Entry].self, from: data)
499
+ else { return [:] }
500
+ return dict.mapValues(\.contextK)
501
+ }()
502
+
503
+ private static func defaultContextLabel(for provider: UsageProvider) -> String? {
504
+ guard let k = self.defaultContextWindows[provider.rawValue] else { return nil }
505
+ return k >= 1_000 ? "ctx \(k / 1_000)M" : "ctx \(k)K"
506
+ }
507
+
480
508
  private func resolvedMenuProvider() -> UsageProvider? {
481
509
  let enabled = self.store.enabledProviders()
482
510
  if enabled.isEmpty { return .codex }
@@ -45,11 +45,8 @@ enum GeminiLoginRunner {
45
45
  }
46
46
 
47
47
  // Create a temporary shell script that runs gemini (auto-prompts for auth when no creds)
48
- let scriptContent = """
49
- #!/bin/bash
50
- cd ~
51
- "\(binary)"
52
- """
48
+ let escaped = binary.replacingOccurrences(of: "'", with: "'\\''")
49
+ let scriptContent = "#!/bin/bash\ncd ~\n'\(escaped)'\n"
53
50
 
54
51
  let tempDir = FileManager.default.temporaryDirectory
55
52
  let scriptURL = tempDir.appendingPathComponent("gemini_login_\(UUID().uuidString).command")
@@ -53,29 +53,17 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainAuggieTokenStore: AuggieTokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }
@@ -53,29 +53,17 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainAzureOpenAITokenStore: AzureOpenAITokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }
@@ -53,29 +53,17 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainCerebrasTokenStore: CerebrasTokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }
@@ -53,29 +53,17 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainCohereTokenStore: CohereTokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }
@@ -53,29 +53,17 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }
@@ -53,29 +53,17 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
53
53
  }
54
54
 
55
55
  let data = cleaned!.data(using: .utf8)!
56
- let query = self.baseQuery(dataProtection: false)
57
- let attributes: [String: Any] = [
58
- kSecValueData as String: data,
59
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
60
- ]
61
-
62
- // Try update first.
63
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
64
- if updateStatus == errSecSuccess { return }
65
56
 
66
- if updateStatus != errSecItemNotFound {
67
- // Item exists but update failed — delete and re-add to reset ACL.
68
- try? self.deleteToken(dataProtection: false)
69
- }
57
+ // Always delete-then-add to ensure SecAccess ACL is set.
58
+ // SecItemUpdate does NOT update the ACL, so existing items with
59
+ // missing or stale ACLs would continue to prompt for passwords.
60
+ try? self.deleteToken(dataProtection: false)
61
+ try? self.deleteToken(dataProtection: true)
70
62
 
71
- var addQuery = query
72
- for (key, value) in attributes { addQuery[key] = value }
63
+ var addQuery = self.baseQuery(dataProtection: false)
64
+ addQuery[kSecValueData as String] = data
65
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
73
66
 
74
- // Grant the calling app permanent access so macOS never shows a
75
- // Keychain password dialog for this item.
76
- if let access = Self.createSelfAccess() {
77
- addQuery[kSecAttrAccess as String] = access
78
- }
79
67
 
80
68
  let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
81
69
  guard addStatus == errSecSuccess else {
@@ -91,6 +79,7 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
91
79
  var query = self.baseQuery(dataProtection: dataProtection)
92
80
  query[kSecMatchLimit as String] = kSecMatchLimitOne
93
81
  query[kSecReturnData as String] = true
82
+ query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
94
83
 
95
84
  let status = SecItemCopyMatching(query as CFDictionary, &result)
96
85
  if status == errSecItemNotFound || status == errSecInteractionNotAllowed {
@@ -127,17 +116,4 @@ struct KeychainDeepSeekTokenStore: DeepSeekTokenStoring {
127
116
  return q
128
117
  }
129
118
 
130
- /// Creates a SecAccess that grants the calling application permanent
131
- /// read/write access without triggering the macOS Keychain password dialog.
132
- private static func createSelfAccess() -> SecAccess? {
133
- var trustedSelf: SecTrustedApplication?
134
- guard SecTrustedApplicationCreateFromPath(nil, &trustedSelf) == errSecSuccess,
135
- let trustedSelf else { return nil }
136
- var access: SecAccess?
137
- guard SecAccessCreate(
138
- "Runic API Token" as CFString,
139
- [trustedSelf] as CFArray,
140
- &access) == errSecSuccess else { return nil }
141
- return access
142
- }
143
119
  }