diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index d3d3923a76..140cf40edf 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -254,7 +254,9 @@ extension CodexBarCLI { } private static func accountSelections(from accounts: [ProviderTokenAccount]) -> [ProviderTokenAccount?] { - if accounts.isEmpty { return [nil] } + if accounts.isEmpty { + return [nil] + } return accounts.map { Optional($0) } } @@ -674,105 +676,125 @@ extension CodexBarCLI { environment: [String: String]? = nil, settings: ProviderSettingsSnapshot? = nil) -> Bool { - guard provider != .grok, provider != .amp else { + if self.webSupportExempt( + sourceMode, + provider: provider, + environment: environment, + settings: settings) + { return false } - if provider == .codex, sourceMode == .auto { - return false + return switch sourceMode { + case .web: + true + case .auto: + ProviderDescriptorRegistry.descriptor(for: provider).fetchPlan.sourceModes.contains(.web) + case .cli, .oauth, .api: + false } - if provider == .claude, sourceMode == .auto { - // Claude's cross-platform planner skips its unavailable web step and falls back to the CLI. - return false + } + + /// Providers that can satisfy a source mode without the macOS-only web/browser path. + private static func webSupportExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, + environment: [String: String]?, + settings: ProviderSettingsSnapshot?) -> Bool + { + if provider == .grok || provider == .amp { + return true } - if provider == .opencodego { - if sourceMode == .auto || settings?.opencodego?.cookieSource == .manual { - return false - } + if sourceMode == .auto, provider == .codex || provider == .claude { + // Claude's cross-platform planner skips its unavailable web step and falls back to the CLI. + return true } - if provider == .commandcode, - settings?.commandcode?.cookieSource == .manual - { - return false + if self.cookieSourceExempt(sourceMode, provider: provider, settings: settings) { + return true } - if provider == .alibabatokenplan, - settings?.alibabaTokenPlan?.cookieSource == .manual - { + return self.credentialExempt( + sourceMode, + provider: provider, + environment: environment, + settings: settings) + } + + /// Exemptions granted by a manual cookie header instead of browser auto-import. + private static func cookieSourceExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, + settings: ProviderSettingsSnapshot?) -> Bool + { + switch provider { + case .opencodego: + return sourceMode == .auto || settings?.opencodego?.cookieSource == .manual + case .commandcode: + return settings?.commandcode?.cookieSource == .manual + case .alibabatokenplan: // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies; only browser // cookie auto-import needs macOS, so a manual cookie header works off macOS too. - return false - } - #if os(Linux) - if provider == .cursor, - settings?.cursor?.cookieSource != .off - { + return settings?.alibabaTokenPlan?.cookieSource == .manual + case .qoder: + return settings?.qoder?.cookieSource == .manual + case .cursor: + #if os(Linux) // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. + return settings?.cursor?.cookieSource != .off + #else return false - } - #endif - if provider == .sakana, - sourceMode == .auto || sourceMode == .web, - environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true - { + #endif + default: return false } - if provider == .qwencloud, - sourceMode == .auto || sourceMode == .web, - settings?.qwenCloud?.cookieSource != .off - { + } + + /// Exemptions granted by an already-configured credential (token, API key, or local cache). + private static func credentialExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, + environment: [String: String]?, + settings: ProviderSettingsSnapshot?) -> Bool + { + switch provider { + case .sakana: + guard sourceMode == .auto || sourceMode == .web else { return false } + return environment.map { SakanaSettingsReader.cookieHeader(environment: $0) != nil } == true + case .qwencloud: + guard sourceMode == .auto || sourceMode == .web, + settings?.qwenCloud?.cookieSource != .off else { return false } let hasEnvironmentCookie = environment.map { QwenCloudSettingsReader.cookieHeader(environment: $0) != nil } == true let hasManualCookie = settings?.qwenCloud?.cookieSource == .manual && CookieHeaderNormalizer.normalize(settings?.qwenCloud?.manualCookieHeader) != nil - if hasEnvironmentCookie || hasManualCookie { - return false - } - } - if provider == .qoder, - settings?.qoder?.cookieSource == .manual - { - return false - } - if provider == .ollama, - sourceMode == .auto - { + return hasEnvironmentCookie || hasManualCookie + case .ollama: + guard sourceMode == .auto else { return false } let hasEnvironmentToken = environment.map { ProviderTokenResolver.ollamaToken(environment: $0) != nil } == true - if settings?.ollama?.cookieSource == .off || hasEnvironmentToken { - return false - } - } - if provider == .kimi, - sourceMode == .auto, - environment.map({ environment in - ProviderTokenResolver.kimiAPIToken(environment: environment) != nil || - KimiSettingsReader.hasKimiCodeCredential(environment: environment) - }) == true - { - return false - } - if provider == .factory, - sourceMode == .auto || sourceMode == .cli, - environment.map({ FactorySettingsReader.apiKey(environment: $0) != nil }) == true - { + return settings?.ollama?.cookieSource == .off || hasEnvironmentToken + case .kimi: + guard sourceMode == .auto else { return false } + return environment.map { environment in + ProviderTokenResolver.kimiAPIToken(environment: environment) != nil || + KimiSettingsReader.hasKimiCodeCredential(environment: environment) + } == true + case .factory: // Linux Auto/legacy-cli can use FACTORY_API_KEY without browser cookies. + guard sourceMode == .auto || sourceMode == .cli else { return false } + return environment.map { FactorySettingsReader.apiKey(environment: $0) != nil } == true + case .minimax: + // The MiniMax API fetch is plain HTTPS + Bearer auth, so a configured key works off + // macOS. Standard `sk-api-` keys are the exception: Auto resolves them to the Coding + // Plan web strategy, which still needs the macOS-only web path. + guard sourceMode == .auto, let environment else { return false } + guard MiniMaxAPISettingsReader.apiToken(environment: environment) != nil else { return false } + return MiniMaxAPISettingsReader.apiKeyKind(environment: environment) != .standard + case .mimo: + guard sourceMode == .auto, let environment else { return false } + return MiMoLocalUsageFallback.cacheExists(environment: environment) + default: return false } - if provider == .mimo, - sourceMode == .auto, - let environment, - MiMoLocalUsageFallback.cacheExists(environment: environment) - { - return false - } - return switch sourceMode { - case .web: - true - case .auto: - ProviderDescriptorRegistry.descriptor(for: provider).fetchPlan.sourceModes.contains(.web) - case .cli, .oauth, .api: - false - } } } diff --git a/TestsLinux/MiniMaxLinuxTests.swift b/TestsLinux/MiniMaxLinuxTests.swift new file mode 100644 index 0000000000..610258cc09 --- /dev/null +++ b/TestsLinux/MiniMaxLinuxTests.swift @@ -0,0 +1,74 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct MiniMaxLinuxTests { + @Test + func `coding plan API key does not require macOS web support`() { + // A coding-plan key resolves to the plain HTTPS + Bearer API strategy, so it must be + // usable off macOS (matches the Factory/Kimi credential exemptions). + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `standard API key still requires web support because Auto resolves to the coding plan page`() { + // `MiniMaxAPIFetchStrategy` refuses standard `sk-api-` keys, so Auto falls back to the + // Coding Plan web strategy. Exempting it here would only produce `noAvailableStrategy`. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-test"])) + } + + @Test + func `auto without an API key still requires web support off macOS`() { + // Without a credential the only remaining Auto path is the web/cookie one, which + // genuinely needs macOS — the gate must still fire. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [:])) + } + + @Test + func `explicit web source still requires web support even with an API key`() { + // The exemption is scoped to Auto; asking for the web source explicitly must not + // be silently redirected to the API path. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `exempted coding plan key actually resolves a linux capable strategy`() async { + // Gate agreement is not enough: the Auto plan must contain a strategy that can run + // without the macOS web path, otherwise the exemption ends in `noAvailableStrategy`. + let env = [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"] + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make(), + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let strategies = await ProviderDescriptorRegistry + .descriptor(for: .minimax) + .fetchPlan + .pipeline + .resolveStrategies(context) + #expect(strategies.contains { $0.id == "minimax.api" }) + } +} +#endif