Skip to content

Commit ed25e20

Browse files
feat(providers): add Codebuff support
Add a new CodexBar provider for Codebuff (https://www.codebuff.com), the successor to the Manicode CLI. The provider surfaces live credit balance and the weekly rate-limit window in the menu bar alongside the other providers. Data sources: * POST https://www.codebuff.com/api/v1/usage -> credit usage / quota / remaining balance / next reset / auto-topup state. * GET https://www.codebuff.com/api/user/subscription -> subscription tier + weekly rate limit + billing period end. Auth token resolution mirrors the Kilo pattern: 1. CODEBUFF_API_KEY env var. 2. API key configured in Settings -> Providers -> Codebuff. 3. ~/.config/manicode/credentials.json (authToken field) written by the official `codebuff login` CLI. New core files under Sources/CodexBarCore/Providers/Codebuff/: CodebuffSettingsReader, CodebuffProviderDescriptor, CodebuffUsageFetcher, CodebuffUsageSnapshot, CodebuffUsageError. App-side files under Sources/CodexBar/Providers/Codebuff/: CodebuffProviderImplementation, CodebuffSettingsStore. Lime-green SVG icon added to Resources. Registers .codebuff in UsageProvider + IconStyle + the descriptor / implementation registries, the token resolver, config env propagation, and all exhaustive switches across CostUsageScanner, UsageStore, CodexBarCLI settings snapshot, and the widget views. Unit tests added: * CodebuffSettingsReaderTests (11 cases) — env, quotes, auth-file parsing, descriptor shape. * CodebuffUsageFetcherTests (16 cases) — URL building, status code mapping, usage/subscription parsers, snapshot -> UsageSnapshot mapping, missing-creds fast-path. * ProviderTokenResolverTests — 3 new cases for env vs auth-file precedence and malformed credentials handling. Verified: `swift build` and targeted `swift test` runs green (27 new Codebuff tests + 12 resolver tests + the provider registry coverage subset). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent e44161f commit ed25e20

23 files changed

Lines changed: 1087 additions & 4 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# CodexBar 🎚️ - May your tokens never run out.
22

3-
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
3+
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, Abacus AI, and Codebuff limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
44

55
<img src="codexbar.png" alt="CodexBar menu screenshot" width="520" />
66

@@ -48,6 +48,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
4848
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
4949
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
5050
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
51+
- [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit.
5152
- Open to new providers: [provider authoring guide](docs/provider.md).
5253

5354
## Icon & Screenshot
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import AppKit
2+
import CodexBarCore
3+
import CodexBarMacroSupport
4+
import Foundation
5+
6+
@ProviderImplementationRegistration
7+
struct CodebuffProviderImplementation: ProviderImplementation {
8+
let id: UsageProvider = .codebuff
9+
10+
@MainActor
11+
func observeSettings(_ settings: SettingsStore) {
12+
_ = settings.codebuffAPIToken
13+
}
14+
15+
@MainActor
16+
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
17+
[
18+
ProviderSettingsFieldDescriptor(
19+
id: "codebuff-api-key",
20+
title: "API key",
21+
subtitle: "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let " +
22+
"CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`).",
23+
kind: .secure,
24+
placeholder: "cb_...",
25+
binding: context.stringBinding(\.codebuffAPIToken),
26+
actions: [
27+
ProviderSettingsActionDescriptor(
28+
id: "codebuff-open-dashboard",
29+
title: "Open Codebuff Dashboard",
30+
style: .link,
31+
isVisible: nil,
32+
perform: {
33+
if let url = URL(string: "https://www.codebuff.com/usage") {
34+
NSWorkspace.shared.open(url)
35+
}
36+
}),
37+
],
38+
isVisible: nil,
39+
onActivate: nil),
40+
]
41+
}
42+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
extension SettingsStore {
5+
var codebuffAPIToken: String {
6+
get { self.configSnapshot.providerConfig(for: .codebuff)?.sanitizedAPIKey ?? "" }
7+
set {
8+
self.updateProviderConfig(provider: .codebuff) { entry in
9+
entry.apiKey = self.normalizedConfigValue(newValue)
10+
}
11+
self.logSecretUpdate(provider: .codebuff, field: "apiKey", value: newValue)
12+
}
13+
}
14+
}

Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ enum ProviderImplementationRegistry {
3939
case .warp: WarpProviderImplementation()
4040
case .perplexity: PerplexityProviderImplementation()
4141
case .abacus: AbacusProviderImplementation()
42+
case .codebuff: CodebuffProviderImplementation()
4243
}
4344
}
4445

Lines changed: 4 additions & 0 deletions
Loading

Sources/CodexBar/UsageStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ extension UsageStore {
880880
let source = resolution?.source.rawValue ?? "none"
881881
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
882882
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
883-
.kimik2, .jetbrains, .perplexity, .abacus:
883+
.kimik2, .jetbrains, .perplexity, .abacus, .codebuff:
884884
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
885885
}
886886
}

Sources/CodexBarCLI/TokenAccountCLI.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ struct TokenAccountCLIContext {
193193
abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
194194
cookieSource: cookieSource,
195195
manualCookieHeader: cookieHeader))
196-
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
196+
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp,
197+
.codebuff:
197198
return nil
198199
}
199200
}

Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment {
3131
}
3232
case .openrouter:
3333
env[OpenRouterSettingsReader.envKey] = apiKey
34+
case .codebuff:
35+
env[CodebuffSettingsReader.apiTokenKey] = apiKey
3436
default:
3537
break
3638
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import CodexBarMacroSupport
2+
import Foundation
3+
4+
@ProviderDescriptorRegistration
5+
@ProviderDescriptorDefinition
6+
public enum CodebuffProviderDescriptor {
7+
static func makeDescriptor() -> ProviderDescriptor {
8+
ProviderDescriptor(
9+
id: .codebuff,
10+
metadata: ProviderMetadata(
11+
id: .codebuff,
12+
displayName: "Codebuff",
13+
sessionLabel: "Credits",
14+
weeklyLabel: "Weekly",
15+
opusLabel: nil,
16+
supportsOpus: false,
17+
supportsCredits: true,
18+
creditsHint: "Credit balance from the Codebuff API",
19+
toggleTitle: "Show Codebuff usage",
20+
cliName: "codebuff",
21+
defaultEnabled: false,
22+
isPrimaryProvider: false,
23+
usesAccountFallback: false,
24+
browserCookieOrder: nil,
25+
dashboardURL: "https://www.codebuff.com/usage",
26+
statusPageURL: nil,
27+
statusLinkURL: nil),
28+
branding: ProviderBranding(
29+
iconStyle: .codebuff,
30+
iconResourceName: "ProviderIcon-codebuff",
31+
color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)),
32+
tokenCost: ProviderTokenCostConfig(
33+
supportsTokenCost: false,
34+
noDataMessage: { "Codebuff cost summary is not yet supported." }),
35+
fetchPlan: ProviderFetchPlan(
36+
sourceModes: [.auto, .api],
37+
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodebuffAPIFetchStrategy()] })),
38+
cli: ProviderCLIConfig(
39+
name: "codebuff",
40+
aliases: ["manicode"],
41+
versionDetector: nil))
42+
}
43+
}
44+
45+
struct CodebuffAPIFetchStrategy: ProviderFetchStrategy {
46+
let id: String = "codebuff.api"
47+
let kind: ProviderFetchKind = .apiToken
48+
49+
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
50+
_ = context
51+
// Keep the strategy available so missing-token surfaces as a user-friendly error
52+
// instead of a generic "no strategy" outcome.
53+
return true
54+
}
55+
56+
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
57+
guard let apiKey = Self.resolveToken(environment: context.env) else {
58+
throw CodebuffUsageError.missingCredentials
59+
}
60+
let usage = try await CodebuffUsageFetcher.fetchUsage(apiKey: apiKey, environment: context.env)
61+
return self.makeResult(
62+
usage: usage.toUsageSnapshot(),
63+
sourceLabel: "api")
64+
}
65+
66+
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
67+
false
68+
}
69+
70+
private static func resolveToken(environment: [String: String]) -> String? {
71+
ProviderTokenResolver.codebuffToken(environment: environment)
72+
}
73+
}
74+
75+
/// Errors related to Codebuff settings.
76+
public enum CodebuffSettingsError: LocalizedError, Sendable {
77+
case missingToken
78+
79+
public var errorDescription: String? {
80+
switch self {
81+
case .missingToken:
82+
"Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " +
83+
"populate ~/.config/manicode/credentials.json."
84+
}
85+
}
86+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
3+
/// Reads Codebuff settings from the environment or the local credentials file
4+
/// that the `codebuff` CLI (formerly `manicode`) writes when the user logs in.
5+
public enum CodebuffSettingsReader {
6+
/// Environment variable key for the Codebuff API token.
7+
public static let apiTokenKey = "CODEBUFF_API_KEY"
8+
9+
/// Returns the API token from environment if present and non-empty.
10+
public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
11+
self.cleaned(environment[self.apiTokenKey])
12+
}
13+
14+
/// Returns the API base URL, defaulting to the production endpoint.
15+
public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
16+
if let override = environment["CODEBUFF_API_URL"],
17+
let url = URL(string: cleaned(override) ?? "")
18+
{
19+
return url
20+
}
21+
return URL(string: "https://www.codebuff.com")!
22+
}
23+
24+
/// Returns the auth token from the local credentials file if present.
25+
public static func authToken(
26+
authFileURL: URL? = nil,
27+
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String?
28+
{
29+
let fileURL = authFileURL ?? self.defaultAuthFileURL(homeDirectory: homeDirectory)
30+
guard let data = try? Data(contentsOf: fileURL) else { return nil }
31+
return self.parseAuthToken(data: data)
32+
}
33+
34+
/// Default on-disk credentials path: `~/.config/manicode/credentials.json`.
35+
static func defaultAuthFileURL(homeDirectory: URL) -> URL {
36+
homeDirectory
37+
.appendingPathComponent(".config", isDirectory: true)
38+
.appendingPathComponent("manicode", isDirectory: true)
39+
.appendingPathComponent("credentials.json", isDirectory: false)
40+
}
41+
42+
static func parseAuthToken(data: Data) -> String? {
43+
guard let payload = try? JSONDecoder().decode(CredentialsFile.self, from: data) else {
44+
return nil
45+
}
46+
return self.cleaned(payload.authToken)
47+
}
48+
49+
static func cleaned(_ raw: String?) -> String? {
50+
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
51+
return nil
52+
}
53+
54+
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
55+
(value.hasPrefix("'") && value.hasSuffix("'"))
56+
{
57+
value.removeFirst()
58+
value.removeLast()
59+
}
60+
61+
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
62+
return value.isEmpty ? nil : value
63+
}
64+
}
65+
66+
private struct CredentialsFile: Decodable {
67+
let authToken: String?
68+
let fingerprintId: String?
69+
let email: String?
70+
let name: String?
71+
}

0 commit comments

Comments
 (0)