Files
2026-07-13 12:22:33 +08:00

225 lines
7.6 KiB
Swift

import Foundation
public struct ClaudeSourcePlanningInput: Equatable, Sendable {
public let runtime: ProviderRuntime
public let selectedDataSource: ClaudeUsageDataSource
public let webExtrasEnabled: Bool
public let hasWebSession: Bool
public let hasCLI: Bool
public let hasOAuthCredentials: Bool
public init(
runtime: ProviderRuntime,
selectedDataSource: ClaudeUsageDataSource,
webExtrasEnabled: Bool,
hasWebSession: Bool,
hasCLI: Bool,
hasOAuthCredentials: Bool)
{
self.runtime = runtime
self.selectedDataSource = selectedDataSource
self.webExtrasEnabled = webExtrasEnabled
self.hasWebSession = hasWebSession
self.hasCLI = hasCLI
self.hasOAuthCredentials = hasOAuthCredentials
}
}
public enum ClaudeSourcePlanReason: String, Equatable, Sendable {
case explicitSourceSelection = "explicit-source-selection"
case appAutoPreferredOAuth = "app-auto-preferred-oauth"
case appAutoFallbackCLI = "app-auto-fallback-cli"
case appAutoFallbackWeb = "app-auto-fallback-web"
case cliAutoPreferredWeb = "cli-auto-preferred-web"
case cliAutoFallbackCLI = "cli-auto-fallback-cli"
}
public struct ClaudeFetchPlanStep: Equatable, Sendable {
public let dataSource: ClaudeUsageDataSource
public let inclusionReason: ClaudeSourcePlanReason
public let isPlausiblyAvailable: Bool
public init(
dataSource: ClaudeUsageDataSource,
inclusionReason: ClaudeSourcePlanReason,
isPlausiblyAvailable: Bool)
{
self.dataSource = dataSource
self.inclusionReason = inclusionReason
self.isPlausiblyAvailable = isPlausiblyAvailable
}
}
public struct ClaudeFetchPlan: Equatable, Sendable {
public let input: ClaudeSourcePlanningInput
public let orderedSteps: [ClaudeFetchPlanStep]
public init(input: ClaudeSourcePlanningInput, orderedSteps: [ClaudeFetchPlanStep]) {
self.input = input
self.orderedSteps = orderedSteps
}
public var availableSteps: [ClaudeFetchPlanStep] {
self.orderedSteps.filter(\.isPlausiblyAvailable)
}
public var isNoSourceAvailable: Bool {
self.availableSteps.isEmpty
}
public var preferredStep: ClaudeFetchPlanStep? {
switch self.input.selectedDataSource {
case .auto:
self.availableSteps.first
case .api, .oauth, .web, .cli:
self.orderedSteps.first
}
}
public var executionSteps: [ClaudeFetchPlanStep] {
switch self.input.selectedDataSource {
case .auto:
self.availableSteps
case .api, .oauth, .web, .cli:
self.orderedSteps
}
}
public var compatibilityStrategy: ClaudeUsageStrategy? {
guard let preferredStep else { return nil }
let useWebExtras = self.input.runtime == .app
&& preferredStep.dataSource == .cli
&& self.input.webExtrasEnabled
return ClaudeUsageStrategy(
dataSource: preferredStep.dataSource,
useWebExtras: useWebExtras)
}
public var orderLabel: String {
self.orderedSteps.map(\.dataSource.sourceLabel).joined(separator: "→")
}
public func debugLines() -> [String] {
var lines = ["planner_order=\(self.orderLabel)"]
lines.append("planner_selected=\(self.preferredStep?.dataSource.rawValue ?? "none")")
lines.append("planner_no_source=\(self.isNoSourceAvailable)")
for step in self.orderedSteps {
let availability = step.isPlausiblyAvailable ? "available" : "unavailable"
lines.append(
"planner_step.\(step.dataSource.rawValue)=\(availability) reason=\(step.inclusionReason.rawValue)")
}
return lines
}
}
public enum ClaudeCLIResolver {
#if DEBUG
@TaskLocal static var resolvedBinaryPathOverrideForTesting: String?
public static var currentResolvedBinaryPathOverrideForTesting: String? {
self.resolvedBinaryPathOverrideForTesting
}
public static func withResolvedBinaryPathOverrideForTesting<T>(
_ path: String?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$resolvedBinaryPathOverrideForTesting.withValue(path) {
try await operation()
}
}
#endif
public static func resolvedBinaryPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current)
-> String?
{
#if DEBUG
if let override = self.resolvedBinaryPathOverrideForTesting {
return FileManager.default.isExecutableFile(atPath: override) ? override : nil
}
#endif
var normalizedEnvironment = environment
if let override = environment["CLAUDE_CLI_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines) {
if override.isEmpty {
normalizedEnvironment.removeValue(forKey: "CLAUDE_CLI_PATH")
} else {
normalizedEnvironment["CLAUDE_CLI_PATH"] = override
if FileManager.default.isExecutableFile(atPath: override) {
return override
}
}
}
return BinaryLocator.resolveClaudeBinary(
env: normalizedEnvironment,
loginPATH: loginPATH)
}
public static func isAvailable(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool {
self.resolvedBinaryPath(environment: environment) != nil
}
}
public enum ClaudeSourcePlanner {
public static func resolve(input: ClaudeSourcePlanningInput) -> ClaudeFetchPlan {
ClaudeFetchPlan(input: input, orderedSteps: self.makeSteps(input: input))
}
private static func makeSteps(input: ClaudeSourcePlanningInput) -> [ClaudeFetchPlanStep] {
switch input.selectedDataSource {
case .auto:
switch input.runtime {
case .app:
[
self.step(.oauth, reason: .appAutoPreferredOAuth, input: input),
self.step(.cli, reason: .appAutoFallbackCLI, input: input),
self.step(.web, reason: .appAutoFallbackWeb, input: input),
]
case .cli:
[
self.step(.web, reason: .cliAutoPreferredWeb, input: input),
self.step(.cli, reason: .cliAutoFallbackCLI, input: input),
]
}
case .api:
[self.step(.api, reason: .explicitSourceSelection, input: input)]
case .oauth:
[self.step(.oauth, reason: .explicitSourceSelection, input: input)]
case .web:
[self.step(.web, reason: .explicitSourceSelection, input: input)]
case .cli:
[self.step(.cli, reason: .explicitSourceSelection, input: input)]
}
}
private static func step(
_ dataSource: ClaudeUsageDataSource,
reason: ClaudeSourcePlanReason,
input: ClaudeSourcePlanningInput) -> ClaudeFetchPlanStep
{
ClaudeFetchPlanStep(
dataSource: dataSource,
inclusionReason: reason,
isPlausiblyAvailable: self.isPlausiblyAvailable(dataSource, input: input))
}
private static func isPlausiblyAvailable(
_ dataSource: ClaudeUsageDataSource,
input: ClaudeSourcePlanningInput) -> Bool
{
switch dataSource {
case .auto, .api:
false
case .oauth:
input.hasOAuthCredentials
case .web:
input.hasWebSession
case .cli:
input.hasCLI
}
}
}