diff --git a/.agents/skills/writing-code/SKILL.md b/.agents/skills/writing-code/SKILL.md index 57e4379..d4fde2b 100644 --- a/.agents/skills/writing-code/SKILL.md +++ b/.agents/skills/writing-code/SKILL.md @@ -38,28 +38,64 @@ Code follows a spec. If there is no spec for the change, stop and use the 6. **Protocol-oriented** — the provider architecture depends on it. Each provider is a struct or actor conforming to `AIProvider`. The hub talks to the protocol, never to a concrete provider. -7. **Comments earn their place** — never restate what the code says. Comment - only business decisions, non-obvious "why", or provider-specific quirks - (e.g., "z.ai uses a 5-hour rolling window, not calendar hours"). If code - needs a comment to be understood, refactor the code first. - - **Never describe past behavior or the change itself.** Comments exist to - explain the current code, not to justify what it replaced or how it - "fixes" something. Words like "replaces", "used to", "previously", - "instead of", or "before this change" are a smell — rewrite the comment - to describe only what the code does now. - - When a comment refers to a spec, cite it in **MLA form** — `(topic NN)`, - e.g. `// peak-hours calculation follows the window defined in (providers 02)`. - See [`writing-specs`](../writing-specs/SKILL.md). - - **Any acceptance-criteria (AC) marker** — e.g. `// AC1`, `// ── AC1 ──`, - `/// AC3 — refresh cadence` — Overall, acceptance criteria SHOULD not be - used unless they provide a clarification that is critical to understand - a piece of code. Must code DO NOT NEED IT, the spec reference (MLA) is - enough most of the time. If it needs to be used then itMUST include the MLA - citation of the spec that owns it. A bare `AC1` is meaningless without its - spec; always write - `// AC1: totals invariant (core 01)` or - `// ── AC3: refresh-on-foreground (core 01) ──`. Code review must reject - any AC marker that lacks a parenthesised spec citation. +7. **Comments are a last resort — the code is the comment.** The default is + no comment at all. Specs justify the code's existence; the code justifies + its own behavior. Most functions, types, and blocks in this repo need + zero comments. New code that adds comments to justify its existence is + wrong — delete the comments, not the code. + + A comment earns its place **only** when it carries information that the + code and the spec together cannot convey. The valid cases, exhaustively: + - A non-obvious **why** — a business rule, external constraint, or + provider-specific quirk the code cannot express by itself. Example: + `// z.ai bills a 5-hour rolling window, not calendar hours`. + - A surprising platform behavior that forced a workaround — stated as a + fact about the current code, never about what it replaced. + + A comment is **not** justified when it: + - **Restates what the code does.** If `Int(percentage.rounded())` is + preceded by `// round to the nearest percent`, the comment is the + problem, not the code. Delete it. + - **Justifies the code's existence or ties it to an acceptance + criterion.** That is the spec's job. "AC1", "satisfies", "implements + spec", or any phrase that exists to prove the line belongs are smells — + cut them and the comment around them. + - **Documents a type or function whose name + signature already convey + it** — e.g. `/// Saves the API key` above `func save(...)`. + - **Describes past behavior or the change itself.** Words like + "replaces", "used to", "previously", "instead of", or "before this + change" are a smell — describe only what the code does now, or delete + the comment. + + **If a comment refers to a spec**, cite it in **MLA form** — `(topic NN)`, + e.g. `// peak-hours window matches (providers 02)`. See + [`writing-specs`](../writing-specs/SKILL.md). Spec citations are optional + and rare — add one only when the *why* genuinely comes from the spec and + the code would read as misleading without it. Do not sprinkle citations + to "prove" the code belongs; that is restating existence, which is banned + above. + + **Acceptance-criteria markers are forbidden.** `// AC1`, `// ── AC3 ──`, + `/// AC5 — fallback`, and every variant are noise: the spec already + enumerates its ACs, the code is the evidence, and a marker cannot be + verified against either. If an AC genuinely shapes a non-obvious + decision, write a plain-English `// why` comment and cite the spec in MLA + form. Delete every existing AC marker on sight. + + **Doc comments (`///`)** — SwiftFormat's `docComments` rule (default-on, + no `.swiftformat` opt-out in this repo) is the authority on `//` vs `///`: + any comment directly above a declaration (public or not) **must** be `///`, + and any comment *not* above a declaration **must** be `//`. So a surviving + *why* that documents a type/function/property is written as `///` — never + downgraded to `//` to "make it less official". That said, the rule is about + the *syntax* of comments that exist, not a license to add them: most + declarations still need no comment at all. Never write a doc comment that + restates the signature. + + When in doubt, delete the comment. If the code then becomes unreadable, + write a better comment that explains only the non-obvious *why* — do not + restore the restatement. + 8. **Dependencies** — if a package is not in `Package.swift`, add it before using it. Prefer Apple frameworks over third-party packages. 9. **Concurrency** — use Swift's structured concurrency (`async/await`, @@ -176,8 +212,11 @@ changed** and verify: - [ ] **Test quality** — do the tests assert the right things, or are they just "tests to pass"? - [ ] **Scope creep** — did you change anything beyond the spec? -- [ ] **MLA citations** — any comment referencing a spec uses `(topic NN)` - form. +- [ ] **Comments are minimal** — every remaining comment explains a + non-obvious *why*, not *what* the code does. No restating the signature, + no AC markers, no justifying existence. When in doubt the comment is gone. +- [ ] **MLA citations** — any comment that *does* reference a spec uses + `(topic NN)` form. No bare AC markers anywhere. - [ ] **No secrets in logs** — no API keys, tokens, or auth headers in `print()`, `os_log`, or debug output. diff --git a/Sources/App/APIKeyEntryState.swift b/Sources/App/APIKeyEntryState.swift index c598e82..b7cc5f4 100644 --- a/Sources/App/APIKeyEntryState.swift +++ b/Sources/App/APIKeyEntryState.swift @@ -1,7 +1,5 @@ import Foundation -/// Transient UI state for an API-key entry row. The key remains only in the -/// secure field until a successful Keychain write clears it. struct APIKeyEntryState { private(set) var input = "" private(set) var errorMessage: String? diff --git a/Sources/App/AppMain.swift b/Sources/App/AppMain.swift index d8a4ee1..45d0089 100644 --- a/Sources/App/AppMain.swift +++ b/Sources/App/AppMain.swift @@ -23,11 +23,6 @@ struct AppMain: App { } var body: some Scene { - // AC1: Menu Bar popover (ui 02). The visible menu-bar content is the - // `label:` view — a live status ring (ui 10). The `MenuBarStatusIcon` - // carries its own accessibility label: a per-state sentence for - // window/balance modes (ui 10 AC9), and "Filbert" for the fallback, - // preserving the original menu-bar announcement. MenuBarExtra { QuotaView(viewModel: viewModel) .frame(width: 280) @@ -36,7 +31,6 @@ struct AppMain: App { } .menuBarExtraStyle(.window) - // AC1: Standalone Settings scene (ui 02) Settings { SettingsView(viewModel: viewModel) } @@ -51,8 +45,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.setActivationPolicy(.accessory) // The app runs as a SwiftPM executable without a full `.app` bundle, so - // set the application icon programmatically from the bundled `.icns`. - // This is what Notification Center widgets and any window chrome pick up. + // the icon is set programmatically from the bundled `.icns`. let iconURL = Bundle.module.url(forResource: "AppIcon", withExtension: "icns") let icon = iconURL.flatMap(NSImage.init(contentsOf:)) if let icon { diff --git a/Sources/App/AppearanceSettingsView.swift b/Sources/App/AppearanceSettingsView.swift index ced8812..af5dbaa 100644 --- a/Sources/App/AppearanceSettingsView.swift +++ b/Sources/App/AppearanceSettingsView.swift @@ -14,8 +14,6 @@ struct AppearanceTab: View { heading: String(localized: "Provider order"), description: String(localized: "Drag providers to change their order.") ) { - // (ui 16) Only configured providers are listed; an empty - // configured set shows a hint instead of a blank list. if viewModel.configuredProvidersOrdered.isEmpty { providerOrderEmptyHint } else { @@ -45,10 +43,6 @@ struct AppearanceTab: View { } private var providerOrderRows: some View { - // (ui 16) The rendered list is the configured subset, but the drop - // delegate and `moveProvider` resolve indices against the full - // ordered list so saved positions of unconfigured (hidden) providers - // are preserved. let visible = viewModel.configuredProvidersOrdered return VStack(spacing: 0) { ForEach(Array(visible.enumerated()), id: \.element.id) { index, provider in @@ -104,9 +98,8 @@ struct AppearanceTab: View { } } - /// Moves a visible row in the direction indicated. Indices are resolved - /// against the full ordered list (`registeredProvidersOrdered`) so the - /// saved positions of unconfigured (hidden) providers stay intact (ui 16). + /// Indices resolve against the full ordered list (`registeredProvidersOrdered`) + /// so saved positions of unconfigured (hidden) providers stay intact. private func moveProvider(at visibleIndex: Int, direction: Int) { let visible = viewModel.configuredProvidersOrdered let destinationVisibleIndex = visibleIndex + direction diff --git a/Sources/App/MenuBarStatusIcon.swift b/Sources/App/MenuBarStatusIcon.swift index bc4888e..b2bc8c1 100644 --- a/Sources/App/MenuBarStatusIcon.swift +++ b/Sources/App/MenuBarStatusIcon.swift @@ -1,17 +1,11 @@ import Core import SwiftUI -// MARK: - Menu-bar live status icon (ui 10) +// MARK: - Menu-bar live status icon -/// The menu-bar label: a ring + short text reflecting the top-most configured -/// provider's live status (ui 10 AC1/AC2). -/// /// The ring is rendered into a bitmap `NSImage` instead of a SwiftUI `Shape` /// because `MenuBarExtra`'s label layer on macOS 14 silently drops arbitrary -/// `Shape` / `Canvas` content — only `Text` and `Image` reliably render. The -/// bitmap is cached per 10% bucket (ui 10 AC6 — rounding) so a refresh that -/// stays inside the same bucket never re-rasterizes (ui 10 AC7 — no extra cost -/// on the existing 5-minute cadence). +/// `Shape` / `Canvas` content — only `Text` and `Image` reliably render. @MainActor struct MenuBarStatusIcon: View { let viewModel: QuotaViewModel @@ -79,7 +73,6 @@ struct MenuBarStatusIcon: View { } } - /// AC5: no usable data — fall back to the static SF Symbol, no text (ui 10). private var fallbackIcon: some View { Image(systemName: "brain.head.profile") .accessibilityLabel(String(localized: "Filbert")) @@ -87,7 +80,6 @@ struct MenuBarStatusIcon: View { // MARK: - Resolution - /// The top-most configured provider's resolved status (ui 10 AC2/AC5). private var resolvedStatus: Resolved? { guard let id = viewModel.configuredProviderIds.first, case let .loaded(quota) = viewModel.providerStates[id] @@ -99,36 +91,28 @@ struct MenuBarStatusIcon: View { return Resolved(providerName: quota.providerName, status: status) } - /// Quantizes a `[0, 1]` fraction into a 10%-bucketed `clampedFraction` - /// (ui 10 AC6). Rounds to the nearest 10%: 0.03 → 0, 0.05 → 0.1, 0.22 → 0.2, - /// 0.97 → 1. Clamped to `[0, 1]` first so out-of-range values stay bounded. private func bucket(from fraction: Double) -> Double { let clamped = QuotaStatusResolver.clampedFraction(fraction) return (clamped * 10).rounded() / 10 } private func percentageText(_ percentage: Double) -> String { - // AC9: rounded to the nearest whole percent for the visible label. let rounded = Int(percentage.rounded()) return String(localized: "\(rounded)%") } private func accessibilityPercentage(providerName: String, percentage: Double) -> String { - // AC9/AC10: a localized human sentence, e.g. "Claude Code: 42% used". let pct = Int(percentage.rounded()) return String(localized: "\(providerName): \(pct)% used") } private func accessibilityBalance(providerName: String, total: Double) -> String { - // AC9/AC10: a localized human sentence, e.g. "DeepSeek: $12.34 remaining". let amount = QuotaStatusResolver.amountText( for: UsageLine(label: "", total: total, unit: nil) ) ?? String(format: "%.2f", total) return String(localized: "\(providerName): \(amount) remaining") } - /// Bundles the resolved status with the provider name so accessibility - /// labels can produce a human sentence without re-reading the view model. private struct Resolved { let providerName: String let status: QuotaStatusResolver.Status @@ -137,15 +121,9 @@ struct MenuBarStatusIcon: View { // MARK: - Ring image (bitmap-backed) -/// SwiftUI wrapper around the cached bitmap ring for a given 10% bucket. -/// -/// The image is drawn in solid black (`Color.black`); `MenuBarExtra` applies -/// its own template tint (black in light mode, white in dark mode, blue while -/// highlighted), so the menu-bar ring respects the OS chrome (ui 10 AC8). +/// The image is drawn in solid black because `MenuBarExtra` applies its own +/// template tint, so the menu-bar ring respects the OS chrome. private struct MenuBarRingImage: View { - /// `[0, 1]` quantized to 10% buckets. Expected to come from - /// `MenuBarStatusIcon.bucket(from:)`; passing an unquantized value still - /// works but defeats the cache. let bucket: Double var body: some View { @@ -156,14 +134,9 @@ private struct MenuBarRingImage: View { } } -/// Lazily-built, process-wide cache of ring bitmaps keyed by 10% bucket. -/// -/// 11 images cover the full 0–100% range (ui 10 AC6). Re-renders only fire -/// when the resolved bucket changes between refreshes, never on every tick -/// (ui 10 AC7). private enum MenuBarRingImageCache { /// `nonisolated(unsafe)`: SwiftUI renders on the main actor, so the lazy - /// populate is single-threaded in practice (ci 04 Plan §4). + /// populate is single-threaded in practice. private nonisolated(unsafe) static var cache: [Int: Image] = [:] static func image(for bucket: Double) -> Image { @@ -177,43 +150,29 @@ private enum MenuBarRingImageCache { return image } - /// Maps a quantized fraction to its 0…10 integer bucket key. private static func bucketKey(_ bucket: Double) -> Int { let clamped = QuotaStatusResolver.clampedFraction(bucket) return Int((clamped * 10).rounded()) } } -/// Draws the ring — a tracked background arc plus a filled foreground arc — -/// into a bitmap `NSImage` using `CGContext`. -/// -/// Drawn in solid black so `MenuBarExtra` can template-tint it (ui 10 AC8). -/// The geometry matches the open-arc look the spec describes: ~85% of the -/// circumference is the drawable arc, the remaining 15% is a fixed visual gap -/// so 100% still reads as a ring with an opening rather than a closed pie -/// (ui 10 AC3/Plan §2). +/// Drawn in solid black so `MenuBarExtra` can template-tint it. private enum MenuBarRingRenderer { - /// Pixel side-length of the rendered bitmap. @2x for crispness on Retina. private static let pixels = 28 - /// 85% of the circumference is drawable; 15% is the fixed gap. private static let drawableRatio: CGFloat = 0.85 - /// Visible stroke width in points (the bitmap is `pixels / 2` points wide). private static let lineWidth: CGFloat = 4 static func render(fraction: Double) -> NSImage { let points = CGFloat(pixels) / 2 guard let context = makeContext() else { - // If bitmap allocation fails, return a transparent image — the - // fallback SF Symbol takes over via the icon's fallback branch. return NSImage(size: NSSize(width: points, height: points)) } drawRing(into: context, fraction: fraction) return makeImage(from: context, points: points) } - /// Allocates the grayscale, alpha-only bitmap the ring is drawn into. private static func makeContext() -> CGContext? { CGContext( data: nil, @@ -226,15 +185,12 @@ private enum MenuBarRingRenderer { ) } - /// Strokes the tracked background arc and the foreground fill arc. /// `CGContext`'s default arc origin is 3 o'clock (0° = positive X), so the - /// context is rotated -90° first to make the arc start at 12 o'clock - /// (ui 10 AC3). + /// context is rotated -90° to make the arc start at 12 o'clock. /// - /// At 100% the gap closes — the arc spans the full `2π` so a complete - /// budget reads as a complete circle rather than a 99% ring. Buckets 0–90% - /// keep the 85% drawable ratio so partial progress still has the open-arc - /// look (ui 10 AC3/Plan §2). + /// At 100% the arc spans the full `2π` so a complete budget reads as a + /// complete circle rather than a 99% ring; buckets 0–90% keep the open-arc + /// gap. private static func drawRing(into context: CGContext, fraction: Double) { let clamped = QuotaStatusResolver.clampedFraction(fraction) let bounds = CGRect(x: 0, y: 0, width: pixels, height: pixels) @@ -249,8 +205,7 @@ private enum MenuBarRingRenderer { context.rotate(by: -.pi / 2) context.translateBy(x: -center.x, y: -center.y) - // Track: the full drawable arc at low opacity. Always uses the gap - // look — even at 100% the track shows where the closing seam sits. + // Track: the full drawable arc at low opacity. context.addArc( center: center, radius: radius, @@ -261,7 +216,6 @@ private enum MenuBarRingRenderer { context.setStrokeColor(CGColor(gray: 0, alpha: 0.2)) context.strokePath() - // Fill: the foreground arc, solid black so MenuBarExtra can tint it. let fillEnd = 2 * .pi * spanRatio * clamped guard fillEnd > 0 else { return } context.addArc( @@ -275,8 +229,6 @@ private enum MenuBarRingRenderer { context.strokePath() } - /// Wraps the rendered bitmap as a template `NSImage` so MenuBarExtra - /// applies its own OS tint (ui 10 AC8). private static func makeImage(from context: CGContext, points: CGFloat) -> NSImage { guard let cgImage = context.makeImage() else { return NSImage(size: NSSize(width: points, height: points)) @@ -303,7 +255,7 @@ private struct MenuBarMacFaceImage: View { private enum MenuBarMacFaceCache { /// `nonisolated(unsafe)`: SwiftUI renders on the main actor, so the lazy - /// populate is single-threaded in practice (ci 04 Plan §4). + /// populate is single-threaded in practice. private nonisolated(unsafe) static var cache: [QuotaStatusResolver.Tier: Image] = [:] static func image(for tier: QuotaStatusResolver.Tier) -> Image { diff --git a/Sources/App/ProviderVisualStyle.swift b/Sources/App/ProviderVisualStyle.swift index 6271125..606f56b 100644 --- a/Sources/App/ProviderVisualStyle.swift +++ b/Sources/App/ProviderVisualStyle.swift @@ -9,8 +9,8 @@ enum ProviderVisualStyle { static let cardCornerRadius: CGFloat = 10 static let neutralContainerFill = Color.secondary.opacity(0.08) - /// Light values retain the contrast-tuned palette from (ui 11); dark mode - /// uses the semantic system colors so it follows the active appearance. + /// Light mode uses a contrast-tuned custom palette; dark mode uses semantic + /// system colors to follow the active appearance. static func tierColor( _ tier: QuotaStatusResolver.Tier, scheme: ColorScheme diff --git a/Sources/App/QuotaStatusResolver.swift b/Sources/App/QuotaStatusResolver.swift index d9a6190..76aa3eb 100644 --- a/Sources/App/QuotaStatusResolver.swift +++ b/Sources/App/QuotaStatusResolver.swift @@ -1,50 +1,30 @@ import Core import Foundation -// MARK: - Shared status resolver (ui 10) +// MARK: - Shared status resolver -/// Pure value type that derives the menu-bar icon's display state from a -/// `ProviderQuota`. -/// -/// The popover (`QuotaView`) and the menu-bar icon (`MenuBarStatusIcon`) both -/// route through this type so the icon and the popover rows can never disagree -/// on which line drives the headline (ui 10 AC3/AC4, ui 04 AC2, ui 08 AC3). -/// There is no SwiftUI here on purpose — `Tests/AppTests` exercises the +/// Both the popover (`QuotaView`) and the menu-bar icon (`MenuBarStatusIcon`) +/// route through this type so they can never disagree on which line drives +/// the headline. No SwiftUI on purpose — `Tests/AppTests` exercises the /// selection rules directly. enum QuotaStatusResolver { enum Tier: Hashable { case good, warn, critical } - /// The icon's resolved display state. enum Status: Equatable { - /// Window-based provider: a percentage arc + `NN%` text (ui 10 AC3). case window(percentage: Double) - /// Balance-based provider: a balance arc + currency text (ui 10 AC4). case balance(used: Double?, total: Double, formattedAmount: String) - /// No usable data — fall back to the static SF Symbol (ui 10 AC5). case fallback } - /// Resolves the icon state for a quota, applying the popover's line- - /// selection rules (ui 10 AC3/AC4): - /// 1. The first line with a non-nil percentage drives `window` mode. - /// The Claude Code provider orders its lines `[5-hour, weekly]` - /// (providers 02 AC5), so first-match gives the 5-hour-before-weekly - /// priority (ui 04 AC2, providers 01 AC5). - /// 2. Otherwise the first positive-total balance-only line drives - /// `balance` mode, mirroring `headlineBalanceColor(for:)` (ui 08 AC3). - /// 3. Otherwise `fallback`. static func resolve(for quota: ProviderQuota) -> Status { - // AC3: percentage wins over balance when both are present on the same - // quota (capped API plans, per core 01). if let percentageLine = firstPercentageLine(in: quota.lines) { if let pct = percentage(for: percentageLine) { return .window(percentage: pct) } } - // AC4: first balance-only line with a positive total drives the ring. if let balanceLine = firstPositiveBalanceLine(in: quota.lines) { if let total = balanceLine.total, total > 0 { let amount = amountText(for: balanceLine) ?? "" @@ -74,20 +54,14 @@ enum QuotaStatusResolver { } } - /// First line carrying a non-nil `percentage(for:)` value (ui 10 AC3). private static func firstPercentageLine(in lines: [UsageLine]) -> UsageLine? { lines.first { percentage(for: $0) != nil } } - /// First balance-only line whose `total` is positive (ui 10 AC4), mirroring - /// `headlineBalanceColor(for:)`'s selection rule (ui 08 AC3). private static func firstPositiveBalanceLine(in lines: [UsageLine]) -> UsageLine? { lines.first { percentage(for: $0) == nil && ($0.total ?? 0) > 0 } } - /// Returns the line's percentage, deriving it from `used / total` when - /// `percentage` is missing (e.g. monthly web-tool calls). Nil when there - /// is no usable percentage data (ui 04 AC1). static func percentage(for line: UsageLine) -> Double? { if let pct = line.percentage { return pct @@ -98,9 +72,6 @@ enum QuotaStatusResolver { return min(max(used / total * 100, 0), 100) } - /// Currency-formatted amount for a balance-only line, using the line's - /// `unit` as the currency code (e.g. "USD", "CNY"). Returns nil when the - /// line has no positive total to format (ui 08 AC3). static func amountText(for line: UsageLine) -> String? { guard let total = line.total, total > 0 else { return nil } let formatter = NumberFormatter() @@ -111,8 +82,6 @@ enum QuotaStatusResolver { return formatter.string(from: NSNumber(value: total)) } - /// Clamps `fraction` to `[0, 1]` before drawing — negative or >100 values - /// never wrap around the track (ui 10 AC6). static func clampedFraction(_ fraction: Double) -> Double { min(max(fraction, 0), 1) } diff --git a/Sources/App/QuotaView.swift b/Sources/App/QuotaView.swift index b52fa4b..850cc73 100644 --- a/Sources/App/QuotaView.swift +++ b/Sources/App/QuotaView.swift @@ -2,14 +2,12 @@ import AppKit import Core import SwiftUI -// MARK: - Multi-provider quota popover (AC4: per-provider sections (ui 02)) +// MARK: - Multi-provider quota popover @MainActor struct QuotaView: View { let viewModel: QuotaViewModel - // AC1: appearance-aware tier palette so both percentage and balance paths - // resolve identical colors that meet WCAG AA in light and dark (ui 11). @Environment(\.colorScheme) private var colorScheme: ColorScheme var body: some View { @@ -17,10 +15,7 @@ struct QuotaView: View { if !viewModel.hasAnyConfiguredProvider { emptyState } else { - // ScrollView is omitted intentionally — MenuBarExtra's - // window-style popover collapses ScrollView to zero height. - // When multiple providers are configured the content is - // still bounded by the popover's intrinsic sizing. + // No ScrollView: MenuBarExtra's window-style popover collapses it to zero height. ForEach(viewModel.configuredProviderIds, id: \.self) { providerId in if let state = viewModel.providerStates[providerId] { providerSection(providerId: providerId, state: state) @@ -48,7 +43,7 @@ struct QuotaView: View { .frame(width: 280) } - // MARK: - Empty state (AC3: no configured providers prompt (ui 02)) + // MARK: - Empty state private var emptyState: some View { VStack(spacing: 8) { @@ -65,7 +60,7 @@ struct QuotaView: View { .padding(.vertical, 16) } - // MARK: - Setup (core 03 AC6: setup reason for .apiKeyFree providers) + // MARK: - Setup private func setupContent(_ reason: String, providerId _: String) -> some View { VStack(alignment: .leading, spacing: 4) { @@ -76,7 +71,7 @@ struct QuotaView: View { .padding(.vertical, 4) } - // MARK: - Loading (AC6: loading indicator (ui 01)) + // MARK: - Loading private var loadingContent: some View { HStack { @@ -89,14 +84,10 @@ struct QuotaView: View { .padding(.vertical, 4) } - // MARK: - Quota content (AC4: render live quota (ui 01); bars + peak (ui 04)) + // MARK: - Quota content private func quotaContent(_ quota: ProviderQuota) -> some View { VStack(alignment: .leading, spacing: 6) { - // AC3: headline gains a tier-indicator Circle for balance-only - // providers; the dot reflects the first balance line's amount - // (ui 08). Percentage-based providers have no balance line, so no - // Circle is drawn. HStack(spacing: 4) { Text(quota.headline) .font(.headline) @@ -108,24 +99,18 @@ struct QuotaView: View { } .padding(.bottom, 2) - // AC1: balance-only lines with non-positive or duplicate totals are - // filtered out before ForEach so SwiftUI sees a stable list (ui 08). - // The headline still surfaces the total, so a true zero balance is - // never hidden from the user. + // balance-only lines with non-positive or duplicate totals are filtered out before ForEach. ForEach(renderedLines(quota.lines), id: \.label) { line in usageLineRow(line) } - // AC3: peak-hours block for providers that supply a config (ui 04). - // Passing lastUpdated ensures SwiftUI re-evaluates the block's - // body on every refresh so the in-peak / off-peak status always - // uses the current time. + // `lastUpdated` forces SwiftUI to re-evaluate the block on every + // refresh so the in-peak / off-peak status always uses the current time. if let peakConfig = quota.peakHoursConfig { PeakHoursBlock(config: peakConfig, lastUpdated: quota.lastUpdated) .padding(.top, 2) } - // AC9: stale-cache hint for providers that set isStale (ui 05). if quota.isStale { staleCacheHint(quota) } @@ -135,9 +120,6 @@ struct QuotaView: View { .padding(.bottom, 4) } - /// Per-provider refresh control (ui 04 AC5, ui 07 AC3/AC4, ui 11 AC4). - /// Icon-only, borderless, disabled and spinning while a refresh is in - /// flight, debounced by the view model. private func refreshButton(for providerId: String, state: ProviderState) -> some View { Button { viewModel.manualRefresh(for: providerId) @@ -201,15 +183,11 @@ struct QuotaView: View { .font(.subheadline.monospacedDigit()) .foregroundColor(percentageColor(pct)) } else if let amount = amountText(for: line) { - // AC3: balance-only row renders only the amount text; the - // tier Circle lives on the headline, not on rows (ui 08). Text(amount) .font(.subheadline.monospacedDigit()) } } - // AC1: horizontal usage bar beneath the label row (ui 04). - // Balance-only rows get no bar (ui 08 AC3). if let pct = percentage(for: line) { UsageBar(percentage: pct, color: percentageColor(pct)) } @@ -240,7 +218,7 @@ struct QuotaView: View { .padding(.vertical, 2) } - // MARK: - Error (AC6: error with Retry (ui 01)) + // MARK: - Error private func errorContent(_ message: String, providerId: String) -> some View { VStack(alignment: .leading, spacing: 6) { @@ -259,7 +237,7 @@ struct QuotaView: View { .padding(.vertical, 4) } - // MARK: - Last updated (AC5: last updated indicator (ui 01)) + // MARK: - Last updated @ViewBuilder private func lastUpdatedLabel(_ quota: ProviderQuota) -> some View { @@ -269,11 +247,8 @@ struct QuotaView: View { .foregroundColor(.secondary) } - // MARK: - Stale-cache hint (ui 05 AC9) + // MARK: - Stale-cache hint - /// Renders two muted lines when the provider flagged its data as stale. - /// The UI never computes freshness — it reads the flag the provider set - /// (ui 05 AC9). @ViewBuilder private func staleCacheHint(_ quota: ProviderQuota) -> some View { let relative = quota.lastUpdated.formatted(.relative(presentation: .named)) @@ -289,30 +264,19 @@ struct QuotaView: View { // MARK: - Helpers - /// Green / orange / red tier shared by the percentage number and the bar, - /// so they can never disagree (ui 04 AC2, ui 11 AC1). Delegates to the - /// appearance-aware palette so light-mode foregrounds pass WCAG AA. private func percentageColor(_ pct: Double) -> Color { let tier = QuotaStatusResolver.tier(for: .window(percentage: pct)) return ProviderVisualStyle.tierColor(tier ?? .good, scheme: colorScheme) } - /// Forwards to the shared resolver so the popover rows and the menu-bar - /// icon share one percentage derivation (ui 10 AC3, ui 04 AC1). private func percentage(for line: UsageLine) -> Double? { QuotaStatusResolver.percentage(for: line) } - /// Filters the provider's lines for display (ui 08 AC1), delegating to - /// the file-level helper that implements the dedup + non-positive rules. private func renderedLines(_ lines: [UsageLine]) -> [UsageLine] { filteredBalanceLines(lines, isPercentageLine: { percentage(for: $0) != nil }) } - /// Tier color for the headline (ui 08 AC3). Derived from the first - /// balance-only line's total — the amount the headline summarizes. - /// Returns nil when the provider has no balance data (percentage-based - /// providers), so no Circle is drawn. private func headlineBalanceColor(for quota: ProviderQuota) -> Color? { guard let line = quota.lines.first(where: { percentage(for: $0) == nil }), let total = line.total @@ -322,14 +286,12 @@ struct QuotaView: View { return ProviderVisualStyle.balanceTierColor(total, scheme: colorScheme) } - /// Forwards to the shared resolver so the popover rows and the menu-bar - /// icon share one currency formatter (ui 10 AC4, ui 08 AC3). private func amountText(for line: UsageLine) -> String? { QuotaStatusResolver.amountText(for: line) } } -// MARK: - Per-provider card (ui 14) +// MARK: - Per-provider card private extension QuotaView { @ViewBuilder @@ -475,12 +437,8 @@ private struct CompactProviderStatus: View { } } -/// Filters lines for display (ui 08 AC1). Drops balance-only lines (those for -/// which `isPercentageLine` returns false) with nil or non-positive `total`. -/// When two balance rows share the same positive amount, only the first (the -/// Total balance in DeepSeek's ordering) survives — duplicate amounts are -/// visually confusing and carry no extra information. Percentage rows always -/// pass. +/// Dedupes balance rows with the same positive amount — duplicate amounts +/// are visually confusing. Percentage rows always pass. private func filteredBalanceLines( _ lines: [UsageLine], isPercentageLine: (UsageLine) -> Bool @@ -498,7 +456,6 @@ private func filteredBalanceLines( guard !isPercentageLine(line) else { return true } guard let total = line.total, total > 0 else { return false } if hasDuplicates { - // Keep only the first positive balance line (Total balance). if firstBalanceKept { return false } @@ -509,16 +466,11 @@ private func filteredBalanceLines( } } -// MARK: - Usage bar (ui 04 AC1/AC2) +// MARK: - Usage bar -/// Thin horizontal progress bar colored by usage tier. Takes the full width -/// its parent gives it, so it auto-fits the popover (ui 04 AC6). -/// -/// Avoids `GeometryReader` — inside the `MenuBarExtra` popover it collapses to -/// zero width because the parent `VStack` doesn't propagate a concrete width -/// before sizing the bar. The fill is a full-width `Capsule` scaled to the -/// used fraction with `scaleEffect`, so it tracks whatever width the popover -/// gives the row without measuring it explicitly. +/// Avoids `GeometryReader` — inside the `MenuBarExtra` popover it collapses +/// to zero width. The fill is a full-width `Capsule` scaled to the used +/// fraction with `scaleEffect` instead. private struct UsageBar: View { let percentage: Double let color: Color @@ -544,21 +496,16 @@ private struct UsageBar: View { } } -// MARK: - Peak-hours block (ui 04 AC3/AC4) +// MARK: - Peak-hours block -/// Renders a provider-agnostic peak-hours status block — the peak window -/// converted to local time, a live in-peak / off-peak indicator, and the -/// current multiplier. -/// -/// All pricing rules come from the `config` parameter; the view has zero -/// knowledge of any specific provider. Computed from `Date()` on each -/// render so the popover stays correct while open (ui 04 AC4). +/// Provider-agnostic: all pricing rules come from `config`, so the view has +/// zero knowledge of any specific provider. Computed from `Date()` on each +/// render so the popover stays correct while open. private struct PeakHoursBlock: View { let config: PeakHoursConfig - /// The last time the provider quota was refreshed. SwiftUI uses this to - /// decide whether to re-evaluate the block's body — a new value on each - /// refresh guarantees `Date()` inside `body` is fresh. + /// SwiftUI uses this to decide whether to re-evaluate the block's body — + /// a new value on each refresh guarantees `Date()` inside `body` is fresh. let lastUpdated: Date var body: some View { @@ -594,8 +541,6 @@ private struct PeakHoursBlock: View { // MARK: - Derived values - /// Builds a "start–end (your time)" label by computing today's peak window - /// boundaries in the config's time zone and formatting them locally. private var localWindowLabel: String { let formatter = DateFormatter() formatter.timeZone = .current @@ -609,9 +554,6 @@ private struct PeakHoursBlock: View { return String(localized: "\(start)–\(end) (your time)") } - /// Today at `hour:00` in the config's time zone — an absolute instant - /// that, when formatted with the user's local time zone, shows the - /// converted time. private func peakWindowBoundary(hour: Int) -> Date { guard let tz = config.timeZone else { return Date() } var cal = Calendar(identifier: .gregorian) diff --git a/Sources/App/QuotaViewModel.swift b/Sources/App/QuotaViewModel.swift index 1de798b..d6cb018 100644 --- a/Sources/App/QuotaViewModel.swift +++ b/Sources/App/QuotaViewModel.swift @@ -13,42 +13,30 @@ final class QuotaViewModel { // MARK: - State - /// Per-provider state map keyed by provider ID (ui 02 Plan 3). - /// /// Must be assigned as a whole value — dictionary subscript mutation - /// does not trigger @Observable's setter. Use setState(_:for:) for all - /// mutations; it also refreshes the derived stored properties. + /// does not trigger @Observable's setter. private(set) var providerStates: [String: ProviderState] = [:] - /// Derived: provider IDs in the user-saved order (ui 09 AC4/AC6). Drives - /// both the popover (`configuredProviderIds` is the configured subset) - /// and the Appearance tab. Reassigned as a whole value so @Observable - /// notifies observers — `ProviderOrder` and `registry` are not observable - /// themselves, so a computed property would not trigger re-renders. + /// Reassigned as a whole value so @Observable notifies observers — + /// `ProviderOrder` and `registry` are not observable, so a computed property + /// would not trigger re-renders. private(set) var orderedProviderIds: [String] = [] - /// Derived: provider IDs with a saved key, sorted by display name (ui 02 AC4). private(set) var configuredProviderIds: [String] = [] - /// Derived: whether any provider is configured (ui 02 AC3). private(set) var hasAnyConfiguredProvider: Bool = false - /// Observation token for UserDefaults-backed collapse choices (ui 14). - /// The values remain in Core; changing this token tells SwiftUI to resolve - /// them again. + /// Changing this token tells SwiftUI to re-resolve the UserDefaults-backed + /// collapse values that live in Core. private var collapseStateRevision = 0 - // MARK: - Quiet refresh (ui 07) + // MARK: - Quiet refresh - /// Per-provider flag set while a refresh is in flight and last-known data - /// stays visible. Drives the Refresh icon's rotation + click-debounce (ui 07 AC3/AC4). private(set) var isRefreshing: [String: Bool] = [:] - /// Per-provider error from the most recent refresh that failed while last-known - /// data was retained. Shown as a non-blocking indicator; cleared on next success (ui 07 AC6). private(set) var refreshErrors: [String: String] = [:] - // MARK: - Auto-refresh (AC7: per-provider 5-minute cadence (ui 02)) + // MARK: - Auto-refresh private var refreshLoops: [String: Task] = [:] @@ -76,7 +64,6 @@ final class QuotaViewModel { setState(.loading, for: info.id) startAutoRefresh(for: info.id) } else { - // Setup state will be filled by refreshAllSetupStates(). setState(.unconfigured, for: info.id) } } @@ -90,12 +77,9 @@ final class QuotaViewModel { // MARK: - Derived properties — public - /// All registered provider metadata in the user-saved order (ui 09 AC4/AC6). - /// - /// Display-name ascending is the fallback both for fresh installs (no - /// saved order) and for newly registered providers that have no saved - /// position (ui 09 AC5). Reads the stored `orderedProviderIds` cache so - /// SwiftUI re-renders when `moveProvider`/`persistOrder` reassign it. + /// Display-name ascending is the fallback for fresh installs and newly + /// registered providers. Reads `orderedProviderIds` (not `registry`) so + /// SwiftUI re-renders when the order changes. var registeredProvidersOrdered: [ProviderInfo] { let byId = Dictionary( uniqueKeysWithValues: registry.registeredProviders.map { ($0.id, $0) } @@ -103,13 +87,8 @@ final class QuotaViewModel { return orderedProviderIds.compactMap { byId[$0] } } - /// Configured providers in the user-saved order (ui 16). - /// - /// Same source as `registeredProvidersOrdered`, filtered to the - /// configured subset so the Appearance tab's "Provider order" matches the - /// popover's configured providers exactly. The configured predicate is - /// shared with `refreshDerived()` so "what counts as configured" is - /// defined in one place — the view layer never re-encodes it. + /// Shares the configured predicate with `refreshDerived()` (via + /// `isConfiguredState`) so "what counts as configured" is defined in one place. var configuredProvidersOrdered: [ProviderInfo] { registeredProvidersOrdered.filter { info in guard let state = providerStates[info.id] else { return false } @@ -117,12 +96,7 @@ final class QuotaViewModel { } } - /// The configured predicate shared by `refreshDerived()` and - /// `configuredProvidersOrdered` (ui 16). - /// - /// `.unconfigured` and `.setup` are excluded; everything else counts as - /// configured. Keep this in sync with the states set by `init` and - /// `setState(_:for:)`. + /// Must stay in sync with the states assigned by `init` and `setState(_:for:)`. private static func isConfiguredState(_ state: ProviderState) -> Bool { switch state { case .unconfigured, .setup: @@ -132,7 +106,7 @@ final class QuotaViewModel { } } - // MARK: - Key management (AC3/AC5: save & clear per provider (ui 02)) + // MARK: - Key management func saveKey(_ key: String, for providerId: String) throws { try keychain.save(key, for: providerId) @@ -151,17 +125,12 @@ final class QuotaViewModel { stopAutoRefresh(for: providerId) } - // MARK: - Base-URL override (ui 03) + // MARK: - Base-URL override - /// Current override URL for a provider, or `nil` if none is saved (ui 03 Plan 2). func overrideURL(for providerId: String) -> URL? { ProviderOverrides.baseURL(for: providerId) } - /// Saves a base-URL override for a provider. Throws `ProviderOverrideError` - /// for non-`https` / empty-host URLs so the view can show an inline error - /// (ui 03 AC3). When the provider is already configured, triggers an - /// immediate re-fetch so the user sees the proxy take effect (ui 03 AC4). func saveOverrideURL(_ url: URL?, for providerId: String) throws { guard !registry.isAPIKeyFree(providerId) else { return } try ProviderOverrides.setBaseURL(url, for: providerId) @@ -173,8 +142,6 @@ final class QuotaViewModel { // MARK: - Setup state refresh - /// Fires `refreshSetupStates()` on the registry and merges the results - /// into `providerStates` for every `.apiKeyFree` provider (ui 05 AC10). private func refreshAllSetupStates() { Task { let states = await registry.refreshSetupStates() @@ -200,7 +167,7 @@ final class QuotaViewModel { } } - // MARK: - Fetch (AC5: manual refresh (ui 02)) + // MARK: - Fetch func fetchQuota(for providerId: String) { guard registry.isConfigured(providerId) else { @@ -211,7 +178,7 @@ final class QuotaViewModel { log("fetchQuota: provider=\(providerId) already loading, skipping") return } - // AC7: debounce — same guard as manual refresh (ui 07). + // debounce while refreshing if isRefreshing[providerId] == true { log("fetchQuota: provider=\(providerId) already refreshing, skipping") return @@ -219,14 +186,10 @@ final class QuotaViewModel { performFetch(for: providerId) } - /// Manual-refresh entry point bound to the popover's Refresh button - /// (providers 03 AC3). Runs the provider's proactive refresh (if it - /// conforms to `ProactiveRefreshable`) before performing the cache read, - /// so a single click both spawns `claude -p` and re-reads the result. - /// - /// Auto-refresh and the initial app-launch fetch still call - /// `fetchQuota(for:)` directly — scheduling a proactive spawn is - /// deferred to a separate spec. + /// Runs the provider's proactive refresh before the cache read, so a single + /// click both spawns the helper and re-reads the result. Auto-refresh and + /// the initial fetch still call `fetchQuota(for:)` directly — proactive + /// spawn is manual-only. func manualRefresh(for providerId: String) { guard registry.isConfigured(providerId) else { log("manualRefresh: provider=\(providerId) not configured, skipping") @@ -236,8 +199,7 @@ final class QuotaViewModel { log("manualRefresh: provider=\(providerId) already loading, skipping") return } - // AC1/AC4: debounce while refreshing; keep last-known data visible (.loaded/.error); - // first-ever fetch still uses .loading (ui 07). + // debounce while refreshing if isRefreshing[providerId] == true { log("manualRefresh: provider=\(providerId) already refreshing, skipping") return @@ -255,9 +217,8 @@ final class QuotaViewModel { } } - /// Background half of `manualRefresh`. Awaits the proactive refresh - /// (catching `.notSupported` so non-conforming providers like ZAI fall - /// through to the standard fetch path) and then runs the fetch. + /// Catches `.notSupported` from the proactive refresh so non-conforming + /// providers fall through to the standard fetch path. private func performManualRefresh(providerId: String) async { do { try await registry.proactiveRefresh(for: providerId) @@ -282,7 +243,6 @@ final class QuotaViewModel { private func performFetch(for providerId: String) { log("performFetch: provider=\(providerId)") - // AC1/AC7: pick the quiet path when last-known data exists (ui 07). switch providerStates[providerId] { case .loaded, .error: setRefreshing(true, for: providerId) @@ -296,7 +256,7 @@ final class QuotaViewModel { } } - // MARK: - Auto-refresh loop (AC7: per-provider 5-minute loop (ui 02)) + // MARK: - Auto-refresh loop private func startAutoRefresh(for providerId: String) { stopAutoRefresh(for: providerId) @@ -320,17 +280,14 @@ final class QuotaViewModel { // MARK: - Helpers - /// Mutate a single provider's state while triggering @Observable's setter. - /// - /// Dictionary subscript mutations only invoke the getter, so the UI would - /// never see the change. Copy-write-back forces the property setter to fire. + /// Copy-write-back forces @Observable's setter to fire — dictionary subscript + /// mutations only invoke the getter, so the UI would never see the change. private func setState(_ state: ProviderState, for providerId: String) { var copy = providerStates copy[providerId] = state providerStates = copy } - /// Recompute stored derived properties from current state. private func refreshDerived() { let byId = Dictionary( uniqueKeysWithValues: registry.registeredProviders.map { ($0.id, $0) } @@ -357,8 +314,7 @@ final class QuotaViewModel { // MARK: - Setup actions extension QuotaViewModel { - /// Returns `true` when the provider's helper can be installed right now - /// (binary present, helper not yet installed) (ui 05 AC3/AC4). + /// Returns `true` when the provider's helper can be installed right now. func canInstallHelper(for providerId: String) -> Bool { registry.canInstallHelper(for: providerId) } @@ -367,8 +323,6 @@ extension QuotaViewModel { registry.credentialImportActionTitle(for: providerId) } - /// Installs the provider's helper, updates state to `.loading` during the - /// operation, and starts auto-refresh + fetch on success (ui 05 AC4). func installHelper(for providerId: String) async { log("installHelper: provider=\(providerId)") setState(.loading, for: providerId) @@ -385,8 +339,6 @@ extension QuotaViewModel { } } - /// Removes the provider's helper, stops auto-refresh, and re-checks the - /// setup state (ui 05 AC5). func removeHelper(for providerId: String) async { log("removeHelper: provider=\(providerId)") setState(.loading, for: providerId) @@ -425,7 +377,7 @@ extension QuotaViewModel { } } -// MARK: - Provider cards (ui 14) +// MARK: - Provider cards extension QuotaViewModel { func providerInfo(for providerId: String) -> ProviderInfo? { @@ -455,12 +407,9 @@ extension QuotaViewModel { } } -// MARK: - Provider ordering (ui 09) +// MARK: - Provider ordering extension QuotaViewModel { - /// Re-orders the registry's providers per a drag-and-drop gesture in the - /// Appearance tab, persists the new order, and refreshes derived state so - /// both the Appearance tab and popover re-render live (ui 09 AC3/AC7). func moveProvider(from source: IndexSet, to destination: Int) { var ids = orderedProviderIds ids.move(fromOffsets: source, toOffset: destination) @@ -469,20 +418,14 @@ extension QuotaViewModel { refreshDerived() } - /// Writes an explicit ordered list of provider IDs and refreshes derived - /// state so both the Appearance tab and popover pick up the new order - /// (ui 09 AC3/AC7). func persistOrder(_ ids: [String]) { ProviderOrder.setOrder(ids) orderedProviderIds = ids refreshDerived() } - /// Recomputes `orderedProviderIds` from the registry + saved order (ui 09). - /// - /// Display-name ascending is the fallback inside the App layer because - /// Core's `ProviderOrder.effectiveOrder(for:)` is name-agnostic. Must be - /// called whenever the registry or the saved order changes. + /// Display-name ascending is the App-layer fallback because Core's + /// `ProviderOrder.effectiveOrder(for:)` is name-agnostic. private func recomputeOrderedProviderIds() { let sortedByName = registry.registeredProviders.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending @@ -491,7 +434,7 @@ extension QuotaViewModel { } } -// MARK: - Quiet-refresh state mutation + result processing (ui 07) +// MARK: - Quiet-refresh state mutation + result processing private extension QuotaViewModel { func setRefreshing(_ refreshing: Bool, for providerId: String) { @@ -517,7 +460,6 @@ private extension QuotaViewModel { log("applyResults: provider=\(id) no longer configured, skipping") continue } - // Every resolved result clears the in-flight flag (ui 07 AC5). setRefreshing(false, for: id) switch result { @@ -528,15 +470,13 @@ private extension QuotaViewModel { case let .failure(error): log("applyResults: provider=\(id) failed: \(error.localizedDescription)") if error is KeychainError { - // Key deleted externally — genuine state change (ui 07 AC6). + // Key deleted externally — genuine state change, not a refresh failure. setRefreshError(nil, for: id) setState(.unconfigured, for: id) stopAutoRefresh(for: id) } else if case .loaded = providerStates[id] { - // AC6: retain last-known quota; surface failure as indicator (ui 07). setRefreshError(error.localizedDescription, for: id) } else { - // AC6 fall-through: no data to retain (ui 07). setRefreshError(nil, for: id) setState(.error(error.localizedDescription), for: id) } diff --git a/Sources/App/RefreshIcon.swift b/Sources/App/RefreshIcon.swift index 9aafb83..48155d3 100644 --- a/Sources/App/RefreshIcon.swift +++ b/Sources/App/RefreshIcon.swift @@ -1,15 +1,10 @@ import SwiftUI -// MARK: - Refresh icon (ui 07 AC3) +// MARK: - Refresh icon -/// `arrow.clockwise` glyph that rotates continuously while `isRefreshing` -/// is true and stops when it flips back. The view-model flag drives both -/// the animation and the click-debounce (ui 07 AC3/AC4). -/// -/// `withAnimation(... .repeatForever)` is committed explicitly on each flag -/// transition: implicit `.animation(_:value:)` was tried first but left the -/// glyph mid-rotation when the flag flipped back, so the explicit form is -/// used (ui 07 Risks). +/// `withAnimation` is committed explicitly on each flag transition because +/// `repeatForever` animations started implicitly do not stop cleanly when the +/// driving value flips back. struct RefreshIcon: View { let isRefreshing: Bool diff --git a/Sources/App/SettingsVisualComponents.swift b/Sources/App/SettingsVisualComponents.swift index c4d7944..f17a76e 100644 --- a/Sources/App/SettingsVisualComponents.swift +++ b/Sources/App/SettingsVisualComponents.swift @@ -15,8 +15,8 @@ private final class SettingsWindowConfigurationView: NSView { super.viewDidMoveToWindow() guard let window else { return } Task { @MainActor [weak window] in - // Settings applies native tab geometry after attachment, so defer - // the resizable style and content minimum until that pass completes (ui 15). + // Settings applies native tab geometry after attachment, so defer the + // style changes until that pass completes. try? await Task.sleep(for: .milliseconds(100)) window?.styleMask.insert(.resizable) window?.contentMinSize = NSSize(width: 520, height: 420) diff --git a/Sources/App/SettingsWindowActivation.swift b/Sources/App/SettingsWindowActivation.swift index fb39142..5dc8960 100644 --- a/Sources/App/SettingsWindowActivation.swift +++ b/Sources/App/SettingsWindowActivation.swift @@ -1,22 +1,16 @@ import AppKit import SwiftUI -/// Wraps a button label so a tap opens the Settings scene and raises its -/// window (ui 16). -/// -/// SwiftUI's `EnvironmentValues.openSettings` reliably creates the Settings -/// window on first open but does not always raise an already-existing window -/// when Filbert runs under the `.accessory` activation policy — a second -/// "Settings…" tap can leave the window behind the frontmost app. This -/// modifier calls `openSettings()`, activates Filbert, and explicitly raises -/// the Settings `NSWindow` so subsequent taps always bring it forward. +/// `EnvironmentValues.openSettings` reliably creates the Settings window on +/// first open but does not always raise an already-existing window under the +/// `.accessory` activation policy, so this modifier also activates Filbert and +/// explicitly raises the Settings `NSWindow`. /// /// The window is matched by structural traits (titled, non-panel) rather than /// localized title text, so the lookup is stable across locales. /// -/// `@MainActor`: SwiftUI invokes `ViewModifier.body` on the main actor, and -/// every touch here (`NSApp`, `windows`, `styleMask`) is MainActor-isolated -/// AppKit (ci 04 Plan §4). +/// `@MainActor`: every touch here (`NSApp`, `windows`, `styleMask`) is +/// MainActor-isolated AppKit. @MainActor private struct OpenAndRaiseSettingsModifier: ViewModifier { @Environment(\.openSettings) private var openSettings @@ -30,9 +24,8 @@ private struct OpenAndRaiseSettingsModifier: ViewModifier { } } - /// Activates Filbert and raises the Settings window on the next main - /// run-loop tick. The dispatch is needed because `openSettings()` does - /// not always create the window synchronously on first open (ui 16). + /// Dispatched to the next run-loop tick because `openSettings()` does not + /// always create the window synchronously on first open. private func raiseSettingsWindow() { NSApp.activate(ignoringOtherApps: true) DispatchQueue.main.async { @@ -41,17 +34,12 @@ private struct OpenAndRaiseSettingsModifier: ViewModifier { } } -/// `@MainActor`: the lookup touches `NSApp.windows`, which is MainActor- -/// isolated AppKit (ci 04 Plan §4). +/// `@MainActor`: `NSApp.windows` is MainActor-isolated AppKit. @MainActor enum SettingsWindowLookup { - /// The Settings scene's `NSWindow`, identified without relying on - /// localized title text (ui 16). - /// - /// The menu-bar popover is hosted in an `NSPanel` without `.titled`; the - /// Settings scene uses a regular titled `NSWindow`. Filtering on those - /// structural traits keeps the lookup locale-independent. If SwiftUI - /// ever exposes a stable scene identifier on macOS 14, prefer that. + /// The menu-bar popover is an `NSPanel` without `.titled`; the Settings scene + /// is a regular titled `NSWindow`. Filtering on those structural traits keeps + /// the lookup locale-independent. static var settingsWindow: NSWindow? { NSApp.windows.first { window in window.styleMask.contains(.titled) && !(window is NSPanel) @@ -60,8 +48,6 @@ enum SettingsWindowLookup { } extension View { - /// Wraps the view in a Button that opens the Settings scene and raises - /// its window (ui 16). func openAndRaiseSettings() -> some View { modifier(OpenAndRaiseSettingsModifier()) } diff --git a/Sources/Core/AnyJSON.swift b/Sources/Core/AnyJSON.swift index 8dbb095..98d3b77 100644 --- a/Sources/Core/AnyJSON.swift +++ b/Sources/Core/AnyJSON.swift @@ -1,18 +1,14 @@ import Foundation -/// A type-erased JSON value that round-trips arbitrary open-schema JSON -/// without exposing the `Any` type (ci 04 AC7). +/// Type-erased JSON value used when a JSON shape is user-owned or externally +/// defined and may carry keys this app does not know about (e.g. Claude Code's +/// `~/.claude/settings.json` accepts arbitrary sibling keys alongside +/// `statusLine`). Holding it as `AnyJSON` — not `[String: Any]` — lets the +/// `Codable` model preserve unknown keys through a read/modify/write cycle +/// while keeping `Sources/` free of the `Any` type. /// -/// Used when a JSON shape is user-owned or externally defined and may carry -/// keys this app does not know about (e.g. Claude Code's `~/.claude/settings.json` -/// accepts arbitrary sibling keys alongside `statusLine`). Holding such a value -/// as `AnyJSON` — not `[String: Any]` — lets the `Codable` model preserve unknown -/// keys through a read/modify/write cycle while keeping `Sources/` free of the -/// `Any` type (ci 04 AC5/AC6). -/// -/// Minimal by design: `Codable` + `Equatable` only. No convenience initializers, -/// no `ExpressibleBy*Literal`, no query helpers — each addition broadens the -/// public API surface of `Core` and belongs in its own decision (ci 04 Risks). +/// Minimal by design: `Codable` + `Equatable` only. Each addition broadens +/// the public API surface of `Core`. public enum AnyJSON: Codable, Sendable, Equatable { case null case bool(Bool) @@ -23,7 +19,6 @@ public enum AnyJSON: Codable, Sendable, Equatable { // MARK: - Decoding - /// /// `decode(Bool.self)` must precede `decode(Double.self)`, and both must /// precede `decode(String.self)`, so the JSON kind is detected unambiguously /// — a JSON `true` is not a `1.0`, and a JSON `"42"` is not a number. diff --git a/Sources/Core/BalanceThresholds.swift b/Sources/Core/BalanceThresholds.swift index 457936f..60316ff 100644 --- a/Sources/Core/BalanceThresholds.swift +++ b/Sources/Core/BalanceThresholds.swift @@ -1,22 +1,10 @@ import Foundation -/// Reads and writes the user-configurable low/ok balance thresholds that -/// drive the amount-tier coloring for balance-only `UsageLine`s (ui 08). -/// -/// Mirrors `ProviderOverrides`: UserDefaults-backed (thresholds are not -/// secrets, so the Keychain stays reserved for API keys per AGENTS.md §3). -/// Raw keys are private so the storage shape can change without touching the -/// App layer (ui 08 AC2). public enum BalanceThresholds { - /// Standard `UserDefaults` the App writes to. Held as a parameter-free - /// accessor so tests can swap it via `setUserDefaults(_:)` — identical - /// pattern to (core 02 Plan 4). - /// - /// `nonisolated(unsafe)`: production never mutates this after startup; only - /// the test-injection API writes, and XCTest runs serially. + // `nonisolated(unsafe)`: production never mutates after startup; only the + // test-injection API writes, and XCTest runs serially. private nonisolated(unsafe) static var defaults: UserDefaults = .standard - /// Returns the saved "low" threshold, or the default when unset (ui 08 AC2). public static var low: Double { if let raw = defaults.object(forKey: Keys.low) as? Double { return raw @@ -24,8 +12,6 @@ public enum BalanceThresholds { return Defaults.low } - /// Returns the saved "ok" threshold, or the default when unset (ui 08 AC2). - /// Named `ok` per spec (ui 08 Plan 1). public static var ok: Double { // swiftlint:disable:this identifier_name if let raw = defaults.object(forKey: Keys.okThreshold) as? Double { return raw @@ -33,9 +19,8 @@ public enum BalanceThresholds { return Defaults.okThreshold } - /// Writes both thresholds, validating `low >= 0` and clamping `ok` upward - /// so `ok > low` always holds. Negative `low` is ignored to keep the UI - /// simple — the stepper already bounds `low >= 0` (ui 08 AC2). + /// Negative `low` is silently ignored, not asserted — the UI stepper + /// already bounds it, so a negative value here is unexpected. public static func set(low: Double, ok okValue: Double) { guard low >= 0 else { return } let clampedOk = max(okValue, low + 1) @@ -43,8 +28,7 @@ public enum BalanceThresholds { defaults.set(clampedOk, forKey: Keys.okThreshold) } - /// Test-only escape hatch: swaps the backing store. Production code never - /// needs this (same pattern as `ProviderOverrides` core 02). + /// Test-only escape hatch: swaps the backing store. public static func setUserDefaults(_ defaults: UserDefaults) { Self.defaults = defaults } diff --git a/Sources/Core/Keychain.swift b/Sources/Core/Keychain.swift index c3b55f6..a8a2ffd 100644 --- a/Sources/Core/Keychain.swift +++ b/Sources/Core/Keychain.swift @@ -18,23 +18,21 @@ import Security /// `applyResults`, the 5-minute auto-refresh) touch the keychain only once /// per session. Writes trust the Security framework status without a /// read-back; `Keychain.lock` is the sole integrity guarantee against -/// concurrent in-process access (core 07 AC2). +/// concurrent in-process access. public final class Keychain: @unchecked Sendable { public static let shared = Keychain() private let service: String private let storage: any KeychainStorage - /// Account under which the consolidated JSON blob is stored. private let account = "providers" - /// Decoded provider-ID → provider-owned secret-field map. `nil` until the - /// first keychain read; an empty dictionary is a valid loaded state. + /// `nil` = not yet loaded; an empty dict is a valid loaded state. private var cache: [String: [String: String]]? - /// Serializes every keychain touch. Held across the (potentially blocking) - /// `SecItem` calls so concurrent readers in `ProviderRegistry.fetchAll` - /// trigger at most one prompt — the first thread reads, the rest wait and - /// then hit the cache. Not reentrant: only the public entry points lock; - /// the private `…Store` helpers assume the lock is already held. + /// Held across the (potentially blocking) `SecItem` calls so concurrent + /// readers in `ProviderRegistry.fetchAll` trigger at most one prompt — the + /// first thread reads, the rest wait and hit the cache. Not reentrant: only + /// the public entry points lock; the private `…Store` helpers assume the + /// lock is already held. private let lock = NSLock() private convenience init() { @@ -69,14 +67,11 @@ public final class Keychain: @unchecked Sendable { return key } - /// Stores provider-owned string fields while preserving every other - /// provider's field map. Core does not interpret field names or values. + /// Core does not interpret field names or values — it stores them opaquely. public func save(_ fields: [String: String], for providerId: String) throws { try mutateStore { $0[providerId] = fields } } - /// Loads a provider-owned field map without assigning meaning to its - /// fields. Missing maps use the same not-found error as API-key loads. public func loadFields(for providerId: String) throws -> [String: String] { lock.lock() defer { lock.unlock() } @@ -94,7 +89,6 @@ public final class Keychain: @unchecked Sendable { private extension Keychain { // MARK: - Store access (all callers hold `lock`) - /// Returns the cached store, loading it from the keychain on first use. private func loadedStore() throws -> [String: [String: String]] { if let cache { return cache @@ -104,9 +98,7 @@ private extension Keychain { return loaded } - /// Loads the store, applies `transform`, and writes it back. The write - /// trusts the Security framework status; on failure the in-memory cache - /// is left at its pre-write state (core 07 AC2). + /// On write failure the in-memory cache is left at its pre-write state. private func mutateStore(_ transform: (inout [String: [String: String]]) -> Void) throws { lock.lock() defer { lock.unlock() } diff --git a/Sources/Core/KeychainAuthenticationContext.swift b/Sources/Core/KeychainAuthenticationContext.swift index 4ea14e6..2103ea8 100644 --- a/Sources/Core/KeychainAuthenticationContext.swift +++ b/Sources/Core/KeychainAuthenticationContext.swift @@ -2,17 +2,14 @@ import AppKit import Foundation import LocalAuthentication -/// Wraps the `LAContext` used for every Keychain authorization in a session. -/// One shared instance is reused by all `SecItem*` calls so macOS can batch +/// One shared `LAContext` is reused by all `SecItem*` calls so macOS can batch /// the user's authorization decisions; a fresh context is created lazily on -/// first use and again after invalidation (core 07 AC1). -/// -/// The wrapper holds no credential data. It only carries the `LAContext` -/// that batches authorization within its valid window. +/// first use and again after invalidation. The wrapper holds no credential +/// data — it only carries the `LAContext` that batches authorization within +/// its valid window. public final class KeychainAuthenticationContext: @unchecked Sendable { - /// Session-scoped singleton. The first access also starts the lifecycle - /// observer so sleep/wake/lock invalidate the underlying `LAContext` - /// (core 07 AC5). + /// First access also starts the lifecycle observer so sleep/wake/lock + /// invalidate the underlying `LAContext`. public static let shared: KeychainAuthenticationContext = { KeychainLifecycleObserver.shared.start() return KeychainAuthenticationContext() @@ -29,11 +26,9 @@ public final class KeychainAuthenticationContext: @unchecked Sendable { storedContext = LAContext() } - /// Tears down the current `LAContext` so the next Keychain access - /// prompts again. Called on system sleep, wake, and session lock, where - /// macOS closes the prior authorization window (core 07 AC5). The - /// consolidated item's in-memory cache is unaffected; only the - /// authorization state resets. + /// Called on system sleep, wake, and session lock, where macOS closes the + /// prior authorization window. The consolidated item's in-memory cache is + /// unaffected; only the authorization state resets. func invalidate() { lock.withLock { storedContext.invalidate() @@ -42,9 +37,6 @@ public final class KeychainAuthenticationContext: @unchecked Sendable { } } -/// Wires system sleep/wake and session lock/unlock notifications to -/// `KeychainAuthenticationContext.shared.invalidate()`. Started once per -/// process on first use of the shared context (core 07 AC5). final class KeychainLifecycleObserver: @unchecked Sendable { static let shared = KeychainLifecycleObserver() diff --git a/Sources/Core/KeychainStorage.swift b/Sources/Core/KeychainStorage.swift index 7b97c3c..ba31403 100644 --- a/Sources/Core/KeychainStorage.swift +++ b/Sources/Core/KeychainStorage.swift @@ -1,10 +1,9 @@ import Foundation import Security -/// Generic-password Keychain storage abstraction. Production code uses -/// `SecurityKeychainStorage`; tests inject an in-memory fake. The shared -/// `KeychainAuthenticationContext` is passed per call so the session's -/// authorization state can be reused (core 07 AC3). +/// Production code uses `SecurityKeychainStorage`; tests inject an in-memory +/// fake. The shared `KeychainAuthenticationContext` is passed per call so the +/// session's authorization state can be reused. public protocol KeychainStorage: Sendable { func readData( service: String, @@ -37,11 +36,10 @@ public extension KeychainStorageError { } } -/// Public accessor for generic-password Keychain items. Wraps -/// `SecItemCopyMatching`, `SecItemUpdate`, `SecItemAdd`, and +/// Wraps `SecItemCopyMatching`, `SecItemUpdate`, `SecItemAdd`, and /// `SecItemDelete` so provider modules stop reinventing SecItem query -/// builders (core 07 AC3). `Keychain` uses this accessor for the -/// consolidated item via the same protocol providers consume. +/// builders. `Keychain` uses this accessor for the consolidated item via the +/// same protocol providers consume. public struct SecurityKeychainStorage: KeychainStorage { public init() {} @@ -50,7 +48,7 @@ public struct SecurityKeychainStorage: KeychainStorage { account: String, authenticationContext: KeychainAuthenticationContext ) throws -> Data? { - // Security framework API: `SecItem*` requires an untyped query (ci 04 AC6). + // `SecItem*` requires an untyped query. // swiftlint:disable:next no_any_type let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -82,7 +80,7 @@ public struct SecurityKeychainStorage: KeychainStorage { account: String, authenticationContext: KeychainAuthenticationContext ) throws { - // Security framework API: `SecItem*` requires an untyped query (ci 04 AC6). + // `SecItem*` requires an untyped query. // swiftlint:disable:next no_any_type let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -90,7 +88,7 @@ public struct SecurityKeychainStorage: KeychainStorage { kSecAttrAccount as String: account, kSecUseAuthenticationContext as String: authenticationContext.localAuthenticationContext, ] - // Security framework API: `SecItem*` requires untyped attributes (ci 04 AC6). + // `SecItem*` requires untyped attributes. // swiftlint:disable:next no_any_type let attributes: [String: Any] = [ kSecValueData as String: data, @@ -119,7 +117,7 @@ public struct SecurityKeychainStorage: KeychainStorage { account: String, authenticationContext: KeychainAuthenticationContext ) { - // Security framework API: `SecItem*` requires an untyped query (ci 04 AC6). + // `SecItem*` requires an untyped query. // swiftlint:disable:next no_any_type let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/Sources/Core/ProviderCollapseState.swift b/Sources/Core/ProviderCollapseState.swift index 617cf45..3b01f16 100644 --- a/Sources/Core/ProviderCollapseState.swift +++ b/Sources/Core/ProviderCollapseState.swift @@ -1,12 +1,10 @@ import Foundation -/// Persists only collapse choices the user explicitly makes (ui 14). -/// /// A missing value stays `nil`; the App layer owns the positional default /// because only it knows the current provider order. public enum ProviderCollapseState { - /// `nonisolated(unsafe)`: production never mutates this after startup; only - /// the test-injection API writes, and XCTest runs serially. + // `nonisolated(unsafe)`: production never mutates after startup; only the + // test-injection API writes, and XCTest runs serially. private nonisolated(unsafe) static var defaults: UserDefaults = .standard public static func collapsedState(for providerId: String) -> Bool? { diff --git a/Sources/Core/ProviderGlyph.swift b/Sources/Core/ProviderGlyph.swift index 44280ee..c511e1b 100644 --- a/Sources/Core/ProviderGlyph.swift +++ b/Sources/Core/ProviderGlyph.swift @@ -1,7 +1,5 @@ import Foundation -/// Provider-owned artwork descriptor used by presentation layers (ui 14). -/// /// Providers keep their glyph assets in their own resource bundles. Core only /// transports the descriptor, so adding a provider does not require an App /// layer switch over provider IDs. diff --git a/Sources/Core/ProviderMetadata.swift b/Sources/Core/ProviderMetadata.swift index 227b9ed..753f3ac 100644 --- a/Sources/Core/ProviderMetadata.swift +++ b/Sources/Core/ProviderMetadata.swift @@ -1,6 +1,5 @@ import Foundation -/// Documentation for a provider's external setup prerequisite. public struct ProviderSetupHelp: Sendable, Equatable { public let linkLabel: String public let url: URL @@ -11,24 +10,17 @@ public struct ProviderSetupHelp: Sendable, Equatable { } } -/// Metadata about a registered provider, surfaced by the registry (ui 02 Plan 1). public struct ProviderInfo: Sendable, Identifiable { public let id: String public let displayName: String public let glyph: ProviderGlyph public let description: String - /// Optional provider-owned notice shown below the Settings controls. public let disclaimer: String? - /// Production host root the provider hits when no override is set (core 02). - /// Surfaced so the Settings UI can show what the user would be overriding - /// (ui 03 Plan 3). public let defaultBaseURL: URL /// Payload-free discriminator so the App layer can dispatch row variants - /// without inspecting a provider ID string (ui 05 AC1). + /// without inspecting a provider ID string. public let authShape: ProviderAuth.Shape - /// Optional documentation for providers with an external setup prerequisite. public let setupHelp: ProviderSetupHelp? - /// Optional title for an explicit provider-owned credential import action. public let credentialImportActionTitle: String? public init( diff --git a/Sources/Core/ProviderOrder.swift b/Sources/Core/ProviderOrder.swift index 14cdd74..210399a 100644 --- a/Sources/Core/ProviderOrder.swift +++ b/Sources/Core/ProviderOrder.swift @@ -1,58 +1,36 @@ import Foundation -/// Reads and writes the user's preferred provider ordering (ui 09). -/// -/// The order lives in `UserDefaults` — it is presentation state, not a -/// secret, so the Keychain stays reserved for API keys (AGENTS.md §3). Raw -/// keys are kept private so the storage shape can change without touching the -/// App layer, mirroring the encapsulation of `ProviderOverrides` (core 02). +/// Order is presentation state, not a secret — `UserDefaults`, not the Keychain +/// (AGENTS.md §3). public enum ProviderOrder { - /// Standard `UserDefaults` the App writes to. Held as a parameterless - /// accessor so tests can swap it via `setUserDefaults(_:)`. - /// - /// `nonisolated(unsafe)`: production sets this once to `.standard` and never - /// mutates it; the only writer is the test-injection API, and XCTest runs - /// tests serially. `UserDefaults` itself is thread-safe for reads/writes. + // `nonisolated(unsafe)`: production sets this once to `.standard` and never + // mutates it; the only writer is the test-injection API, and XCTest runs + // tests serially. `UserDefaults` itself is thread-safe for reads/writes. private nonisolated(unsafe) static var defaults: UserDefaults = .standard - /// Returns the passed provider IDs re-sorted so saved-order IDs come first - /// in their saved sequence, then any unsaved IDs in their original - /// relative order (ui 09 AC4/AC5/AC6). - /// - /// The caller passes `[String]`, not display names, so Core stays - /// name-agnostic; the view model resolves names from `ProviderInfo`. - /// Saved IDs that are no longer in `providerIds` are dropped on read. - /// `providerIds` that are not in the saved list keep their input order - /// after the saved ones. public static func effectiveOrder(for providerIds: [String]) -> [String] { let saved = savedOrder() ?? [] let savedSet = Set(saved) let inputSet = Set(providerIds) - // Saved IDs that are still registered, in their saved sequence. let savedKnown = saved.filter { inputSet.contains($0) } - // Registered IDs with no saved position, in the caller's order. let unsaved = providerIds.filter { !savedSet.contains($0) } return savedKnown + unsaved } - /// Raw read of the saved order, or `nil` if none is stored (ui 09 Plan 1). - /// - /// Used only to seed the editor; `effectiveOrder(for:)` is the call sites - /// should use for resolution. Filtering against the live registry is the - /// caller's responsibility here. + /// Prefer `effectiveOrder(for:)` for live resolution; this raw read is used + /// only to seed the editor. Filtering against the live registry is the + /// caller's responsibility. public static func savedOrder() -> [String]? { defaults.array(forKey: storageKey) as? [String] } - /// Writes the full ordered list of provider IDs (ui 09 AC3). public static func setOrder(_ providerIds: [String]) { defaults.set(providerIds, forKey: storageKey) } - /// Test-only escape hatch: swaps the backing store. Production code never - /// needs this. + /// Test-only escape hatch: swaps the backing store. public static func setUserDefaults(_ defaults: UserDefaults) { Self.defaults = defaults } diff --git a/Sources/Core/ProviderOverrides.swift b/Sources/Core/ProviderOverrides.swift index 201022c..73648cb 100644 --- a/Sources/Core/ProviderOverrides.swift +++ b/Sources/Core/ProviderOverrides.swift @@ -1,23 +1,14 @@ import Foundation -/// Reads and writes per-provider base-URL overrides (core 02). -/// -/// Overrides live in `UserDefaults` — they are not secrets, so the Keychain -/// stays reserved for API keys (AGENTS.md §3). Raw keys are kept private so -/// the storage shape can change without touching the App layer (core 02 AC5). +/// Overrides are not secrets — `UserDefaults`, not the Keychain (AGENTS.md §3). public enum ProviderOverrides { - /// Standard `UserDefaults` the App writes to. Held as a parameter-free - /// accessor so tests can swap it via `setUserDefaults(_:for:)`. - /// - /// `nonisolated(unsafe)`: production never mutates this after startup; only - /// the test-injection API writes, and XCTest runs serially. + // `nonisolated(unsafe)`: production never mutates after startup; only the + // test-injection API writes, and XCTest runs serially. private nonisolated(unsafe) static var defaults: UserDefaults = .standard - /// Returns the saved override URL for a provider, or `nil` if none. - /// /// Invalid entries (unparseable, empty host, or non-`https`) are treated /// as unset and removed — a working default is always preferable to a - /// confusing URL error (core 02 AC6). + /// confusing URL error. public static func baseURL(for providerId: String) -> URL? { let raw = defaults.string(forKey: key(for: providerId)) guard let raw, @@ -35,10 +26,7 @@ public enum ProviderOverrides { return url } - /// Saves an override URL for a provider. Pass `nil` to clear. - /// - /// Only `https` URLs with a non-empty host are accepted; anything else - /// throws (core 02 AC5). + /// Pass `nil` to clear. @discardableResult public static func setBaseURL(_ url: URL?, for providerId: String) throws -> URL? { guard let url else { @@ -58,8 +46,7 @@ public enum ProviderOverrides { return url } - /// Test-only escape hatch: swaps the backing store. Production code never - /// needs this. + /// Test-only escape hatch: swaps the backing store. public static func setUserDefaults(_ defaults: UserDefaults) { Self.defaults = defaults } diff --git a/Sources/Core/ProviderProtocol.swift b/Sources/Core/ProviderProtocol.swift index bddb0e9..ed2a8b6 100644 --- a/Sources/Core/ProviderProtocol.swift +++ b/Sources/Core/ProviderProtocol.swift @@ -1,10 +1,5 @@ import Foundation -/// Shared quota model that represents any provider plan type (core 01). -/// -/// A single provider can return a mix of line types: some with percentage -/// + resetDate (windowed quota), others with used + unit (continuous -/// consumption), and some with both (capped API plans). public struct ProviderQuota: Sendable { public let providerId: String public let providerName: String @@ -12,9 +7,6 @@ public struct ProviderQuota: Sendable { public let lines: [UsageLine] public let lastUpdated: Date public let error: String? - /// Provider-set flag indicating the data is beyond its freshness window. - /// Defaults to `false`; the provider is solely responsible for setting it - /// (providers 02 AC5b). public let isStale: Bool /// Optional peak-hours pricing config. Providers that have time-based /// multipliers (e.g. z.ai's GLM Coding Plan) populate this so the view @@ -44,32 +36,21 @@ public struct ProviderQuota: Sendable { // MARK: - Peak-hours config -/// Provider-agnostic peak-hours pricing configuration. -/// -/// Providers with time-based tiers (peak/off-peak multipliers) populate this -/// on their `ProviderQuota`. The view layer renders it generically — adding a -/// new provider with peak hours requires no changes to `QuotaView.swift`. +/// Provider-agnostic: adding a new provider with peak hours requires no +/// changes to the view layer. public struct PeakHoursConfig: Sendable { - /// The time zone the peak window is defined in (e.g. Asia/Shanghai). public let timeZone: TimeZone? - /// Peak window: `[peakStartHour, peakEndHour)` in `timeZone`. + /// Peak window is `[peakStartHour, peakEndHour)` in `timeZone`. public let peakStartHour: Int public let peakEndHour: Int - /// Multiplier while inside the peak window. public let peakMultiplier: Int - /// Multiplier outside the peak window (used when no promo is active). public let offPeakMultiplier: Int - /// Optional promotional off-peak multiplier, valid until `promoEndDate`. - /// When non-nil and the date hasn't passed, this replaces - /// `offPeakMultiplier` for off-peak hours. public let promoMultiplier: Int? - /// End date of the promotional multiplier, if any. After this date - /// the off-peak multiplier reverts to `offPeakMultiplier`. public let promoEndDate: Date? public init( @@ -92,8 +73,6 @@ public struct PeakHoursConfig: Sendable { // MARK: - Queries - /// True iff `date`, interpreted in `timeZone`, falls in - /// `[peakStartHour, peakEndHour)`. public func isInPeak(at date: Date) -> Bool { guard let timeZone else { return false } var cal = Calendar(identifier: .gregorian) @@ -102,10 +81,6 @@ public struct PeakHoursConfig: Sendable { return hour >= peakStartHour && hour < peakEndHour } - /// The current multiplier at `date`: - /// - `peakMultiplier` inside the peak window, - /// - `promoMultiplier` off-peak while the promo is active, - /// - `offPeakMultiplier` otherwise. public func multiplier(at date: Date) -> Int { if isInPeak(at: date) { return peakMultiplier @@ -117,11 +92,6 @@ public struct PeakHoursConfig: Sendable { } } -/// One row in the quota display. Label is the only required field (core 01). -/// -/// Lines are flexible: some carry percentage + resetDate (windowed quota), -/// others carry used + unit (continuous consumption), and some carry both -/// (capped API plans with a percentage-remaining ceiling). public struct UsageLine: Sendable { public let label: String public let used: Double? @@ -150,7 +120,6 @@ public struct UsageLine: Sendable { } } -/// A single key-value detail line, e.g. "RPM" : "42 / 500". public struct UsageDetail: Sendable { public let label: String public let value: String @@ -161,116 +130,91 @@ public struct UsageDetail: Sendable { } } -// MARK: - ProviderAuth (core 03) +// MARK: - ProviderAuth -/// The authentication shape a provider needs to fetch its quota (core 03 AC1). -/// -/// Future auth shapes (e.g. OAuth tokens) get their own case under a separate -/// spec — never bolted on as a Stringly-typed field. +/// Future auth shapes (e.g. OAuth tokens) get their own case — never bolted on +/// as a Stringly-typed field. public enum ProviderAuth: Sendable { - /// A plaintext API key stored in the macOS Keychain. case apiKey(String) - /// No API key is needed — the provider derives its auth from the local - /// environment (installed binary, helper process, cache file, etc.). + /// The provider derives its auth from the local environment (installed + /// binary, helper process, cache file, etc.) — no API key needed. case apiKeyFree /// Payload-free discriminator so the registry can route without ever - /// materializing a key it should not see (core 03 AC7). + /// materializing a key it should not see. public enum Shape: Sendable { case apiKey case apiKeyFree } } -// MARK: - AIProvider (core 01, updated core 03) +// MARK: - AIProvider -/// Protocol every AI provider module must conform to (core 01). -/// -/// Each provider is responsible for formatting its own headline string — -/// the Core layer never interprets it. +/// Each provider formats its own headline string — Core never interprets it. /// -/// The effective base URL is resolved by Core (default vs. per-provider -/// override) and passed into `fetchQuota` so providers never read the -/// override themselves (core 02). +/// Core resolves the effective base URL (default vs. per-provider override) +/// and passes it into `fetchQuota` so providers never read the override +/// themselves. public protocol AIProvider: Sendable { static var providerId: String { get } static var providerName: String { get } static var providerGlyph: ProviderGlyph { get } - /// Short, localized description shown in the Settings provider list (ui 02). static var providerDescription: String { get } - /// Optional localized notice shown in Settings for providers that require - /// an explicit caveat about their integration. static var providerDisclaimer: String? { get } - /// Production host root for this provider, e.g. - /// `URL(string: "https://api.z.ai")!` (core 02 AC1). Path segments stay - /// inside `fetchQuota`. + /// Host root only; path segments stay inside `fetchQuota`. static var baseURL: URL { get } /// Non-payload discriminator the registry branches on so it never - /// materializes a key it should not see (core 03 AC4/AC7). + /// materializes a key it should not see. static var authShape: ProviderAuth.Shape { get } - /// Optional, provider-supplied documentation for an external setup - /// prerequisite (ui 13). static var setupHelp: ProviderSetupHelp? { get } - /// Localized title for a deliberate credential-import action. `nil` means - /// this provider has no external credential source (core 04 AC7). + /// `nil` means this provider has no external credential source. static var credentialImportActionTitle: String? { get } // MARK: - Quota fetch - /// Fetches the provider's current quota, hitting `` resolved by - /// the registry (core 02 AC2, core 03 AC2). - /// - /// - Parameter auth: The provider's authentication shape. Providers that - /// expect `.apiKey` should pattern-match `.apiKey(let key)`; providers - /// that are `.apiKeyFree` should never see `.apiKey` and vice versa. - /// - Parameter baseURL: The effective host root, either the provider's - /// default or a user-saved proxy override. + /// Providers that expect `.apiKey` should pattern-match `.apiKey(let key)`; + /// providers that are `.apiKeyFree` never see `.apiKey`, and vice versa. func fetchQuota(auth: ProviderAuth, baseURL: URL) async throws -> ProviderQuota - // MARK: - Configuration (core 03 AC5/AC6) + // MARK: - Configuration - /// Whether this provider is ready to fetch. The default returns `true`, - /// which is only correct for `.apiKey` providers — the registry routes - /// `.apiKey` providers through the Keychain path and never calls this. - /// `.apiKeyFree` providers MUST override to return their real state - /// (binary present, helper installed, etc.). + /// The default returns `true`, which is only correct for `.apiKey` + /// providers — the registry routes `.apiKey` providers through the + /// Keychain path and never calls this. `.apiKeyFree` providers MUST + /// override to return their real state (binary present, helper installed, + /// etc.). func isConfigured() -> Bool - /// `.apiKeyFree` providers return their current setup state, e.g. why the - /// provider is not ready. `.apiKey` providers never need this — the - /// registry never calls it for them (core 03 AC6). + /// `.apiKeyFree` providers return their current setup state (e.g. why the + /// provider is not ready). `.apiKey` providers never need this — the + /// registry never calls it for them. func currentSetupState() async -> ProviderState? - // MARK: - Helper management (ui 05 AC4/AC5) + // MARK: - Helper management - /// Installs the provider's helper (binary, config file, etc.). Only - /// `.apiKeyFree` providers that require a local helper override this; - /// the default throws `ProviderSetupError.notSupported` (ui 05 Plan 2). + /// Only `.apiKeyFree` providers that require a local helper override this; + /// the default throws `ProviderSetupError.notSupported`. func installHelper() async throws - /// Removes the provider's helper and restores any configuration the - /// install touched. Only `.apiKeyFree` providers override this (ui 05 AC5). + /// Restores any configuration the install touched. Only `.apiKeyFree` + /// providers override this. func removeHelper() async throws - /// Whether the helper can currently be installed (binary present, etc.). /// Returns `false` for `.apiKey` providers and for `.apiKeyFree` providers - /// whose binary is missing (ui 05 AC3/AC4). + /// whose binary is missing. func canInstallHelper() -> Bool - /// Imports credentials from a provider-owned external source when the - /// user explicitly requests it (core 04 AC7). + /// Provider-owned external source; only on explicit user action. func importCredentials() async throws } -// MARK: - AIProvider defaults (core 03 AC3/AC5/AC6) +// MARK: - AIProvider defaults public extension AIProvider { static var providerGlyph: ProviderGlyph { .sfSymbol("cpu") } - /// Defaults to `.apiKey` so existing providers need zero changes beyond - /// the `fetchQuota` signature (core 03 Plan 2/3). static var authShape: ProviderAuth.Shape { .apiKey } @@ -287,15 +231,13 @@ public extension AIProvider { nil } - /// Defaults to `true` — only correct for `.apiKey` providers. The - /// registry routes `.apiKey` providers through the Keychain path and - /// never calls this (core 03 AC5). + /// Defaults to `true` — only correct for `.apiKey` providers, which the + /// registry routes through the Keychain path, never calling this. func isConfigured() -> Bool { true } - /// Defaults to `nil` — `.apiKey` providers are never asked for setup - /// state (core 03 AC6). + /// Defaults to `nil` — `.apiKey` providers are never asked for setup state. func currentSetupState() async -> ProviderState? { nil } @@ -321,18 +263,16 @@ public extension AIProvider { } } -/// Per-provider state the view model tracks (ui 02 Plan 2, core 03 AC6). public enum ProviderState: Sendable { case unconfigured - /// `.apiKeyFree` providers report why they aren't ready via a - /// human-readable reason (core 03 AC6). + /// The associated value is a human-readable reason the provider isn't ready. case setup(String) case loading case loaded(ProviderQuota) case error(String) } -// MARK: - ProviderSetupError (ui 05 Plan 2) +// MARK: - ProviderSetupError /// Thrown by `AIProvider.installHelper()` / `removeHelper()` default /// implementations when called on a provider that does not support helper @@ -351,25 +291,19 @@ extension ProviderSetupError: LocalizedError { } } -// MARK: - ProactiveRefreshable (providers 03) +// MARK: - ProactiveRefreshable -/// Opt-in capability for providers that can refresh their data on demand -/// before the next `fetchQuota` call (providers 03 AC3). -/// -/// Conformance is optional: providers that derive their data purely from -/// the network on every fetch (e.g. `.apiKey` providers like ZAI) never -/// conform, and the registry reports `ProviderSetupError.notSupported` for -/// them. Providers whose data comes from a side channel that an external -/// action can refresh (e.g. Claude Code's statusline cache) conform and -/// implement `proactiveRefresh()` to trigger that action. +/// Opt-in: providers that derive data purely from the network on every fetch +/// (e.g. `.apiKey` providers like ZAI) never conform, and the registry reports +/// `ProviderSetupError.notSupported` for them. Providers whose data comes from +/// a side channel that an external action can refresh (e.g. Claude Code's +/// statusline cache) conform and implement `proactiveRefresh()` to trigger +/// that action. public protocol ProactiveRefreshable: AIProvider { - /// Trigger an out-of-band refresh of the provider's data source. - /// /// Implementations should block until the refresh is observably complete /// (e.g. the cache file has been rewritten) or a bounded timeout elapses, /// so the caller's subsequent `fetchQuota` reads fresh data. Failures - /// should throw; the view model catches them and proceeds to - /// `fetchQuota` regardless, surfacing whatever cached data is available - /// (providers 03 AC3). + /// should throw; the view model catches them and proceeds to `fetchQuota` + /// regardless, surfacing whatever cached data is available. func proactiveRefresh() async throws } diff --git a/Sources/Core/ProviderRegistry.swift b/Sources/Core/ProviderRegistry.swift index 3144cba..9d7da78 100644 --- a/Sources/Core/ProviderRegistry.swift +++ b/Sources/Core/ProviderRegistry.swift @@ -1,11 +1,9 @@ import Foundation -/// MainActor-isolated registry of configured providers. -/// /// All mutation happens on the MainActor (`AppMain.init()` registers providers /// before the view model reads them; `QuotaViewModel` is `@MainActor`). /// MainActor isolation implies `Sendable` (SE-0306/SE-0338), so the registry -/// crosses `Task` boundaries safely without an `@unchecked` escape hatch (ci 04). +/// crosses `Task` boundaries without an `@unchecked` escape hatch. @MainActor public final class ProviderRegistry { private var providers: [String: any AIProvider] = [:] @@ -18,7 +16,6 @@ public final class ProviderRegistry { providers[id] = provider } - /// List of all registered providers with their metadata (ui 02 AC2/AC9). public var registeredProviders: [ProviderInfo] { providers.values.map { provider in ProviderInfo( @@ -35,11 +32,9 @@ public final class ProviderRegistry { } } - /// Whether the given provider is ready to fetch (ui 02 AC3/AC5, core 03 AC5). - /// - /// For `.apiKey` providers this checks the Keychain. For `.apiKeyFree` - /// providers this delegates to the provider's own `isConfigured()` — - /// the provider owns what "configured" means for its auth shape. + /// For `.apiKey` providers this checks the Keychain; for `.apiKeyFree` + /// providers it delegates to the provider's own `isConfigured()` — the + /// provider owns what "configured" means for its auth shape. public func isConfigured(_ providerId: String) -> Bool { guard let provider = providers[providerId] else { return false } let shape = type(of: provider).authShape @@ -52,9 +47,8 @@ public final class ProviderRegistry { } public func fetchAll() async -> [String: Result] { - // Snapshot the dictionary and keychain into locals before crossing - // into the TaskGroup so child tasks never touch MainActor-isolated - // state (ci 04 Plan §4). + // Snapshot before crossing into the TaskGroup so child tasks never + // touch MainActor-isolated state. let snapshot = providers let keychain = keychain @@ -74,8 +68,6 @@ public final class ProviderRegistry { case .apiKeyFree: auth = .apiKeyFree } - // Core resolves the effective URL: user override when - // present and valid, else the provider's default (core 02 AC2/AC6). let baseURL = ProviderOverrides.baseURL(for: providerId) ?? type(of: provider).baseURL let quota = try await provider.fetchQuota( @@ -97,16 +89,15 @@ public final class ProviderRegistry { } } - // MARK: - Setup state (core 03 AC6) + // MARK: - Setup state /// Fires `currentSetupState()` on every registered `.apiKeyFree` provider - /// concurrently. `.apiKey` providers are not called — their setup state - /// is always `nil` (core 03 AC6). - /// - /// The view model calls this at launch and after install/uninstall actions - /// to re-sync setup state without blocking the main actor. + /// concurrently. `.apiKey` providers are not called — their setup state is + /// always `nil`. The view model calls this at launch and after + /// install/uninstall actions to re-sync without blocking the main actor. public func refreshSetupStates() async -> [String: ProviderState] { - // Snapshot before crossing into the TaskGroup (ci 04 Plan §4). + // Snapshot before crossing into the TaskGroup so child tasks never + // touch MainActor-isolated state. let snapshot = providers return await withTaskGroup( @@ -132,26 +123,22 @@ public final class ProviderRegistry { } } - // MARK: - Helper management (ui 05) + // MARK: - Helper management - /// Returns `true` when the provider's auth shape is `.apiKeyFree` - /// (ui 05 AC8). The popover uses this to suppress the "Clear Key" button. + /// The popover uses this to suppress the "Clear Key" button. public func isAPIKeyFree(_ providerId: String) -> Bool { guard let provider = providers[providerId] else { return false } return type(of: provider).authShape == .apiKeyFree } - /// Returns `true` when the `.apiKeyFree` provider's helper can be - /// installed right now (binary is present). `.apiKey` providers always - /// return `false` — they have no helper (ui 05 AC3/AC4). + /// `.apiKey` providers always return `false` — they have no helper. public func canInstallHelper(for providerId: String) -> Bool { guard let provider = providers[providerId] else { return false } return provider.canInstallHelper() } - /// Delegates to the provider's `installHelper()`. Throws when the - /// provider is not registered or does not support helper installation - /// (ui 05 AC4). + /// Throws when the provider is not registered or does not support helper + /// installation. public func installHelper(for providerId: String) async throws { guard let provider = providers[providerId] else { throw ProviderSetupError.notSupported @@ -159,9 +146,8 @@ public final class ProviderRegistry { try await provider.installHelper() } - /// Delegates to the provider's `removeHelper()`. Throws when the - /// provider is not registered or does not support helper removal - /// (ui 05 AC5). + /// Throws when the provider is not registered or does not support helper + /// removal. public func removeHelper(for providerId: String) async throws { guard let provider = providers[providerId] else { throw ProviderSetupError.notSupported @@ -169,16 +155,15 @@ public final class ProviderRegistry { try await provider.removeHelper() } - // MARK: - Credential import (core 04) + // MARK: - Credential import - /// Returns the provider-owned title for an explicit credential import, - /// or `nil` when the provider does not support one (core 04 AC7). + /// Returns `nil` when the provider does not support credential import. public func credentialImportActionTitle(for providerId: String) -> String? { providers[providerId].map { type(of: $0).credentialImportActionTitle } ?? nil } /// Routes an explicit credential import without inspecting a provider ID - /// or provider-specific credential shape (core 04 AC7). + /// or provider-specific credential shape. public func importCredentials(for providerId: String) async throws { guard let provider = providers[providerId] else { throw ProviderSetupError.notSupported @@ -186,14 +171,12 @@ public final class ProviderRegistry { try await provider.importCredentials() } - // MARK: - Proactive refresh (providers 03) + // MARK: - Proactive refresh - /// Triggers an out-of-band refresh on providers that conform to - /// `ProactiveRefreshable` (providers 03 AC3). Throws - /// `ProviderSetupError.notSupported` when the provider is not registered - /// or does not conform — the view model catches this and proceeds - /// straight to `fetchQuota`, so non-conforming providers behave - /// identically to before. + /// Throws `ProviderSetupError.notSupported` when the provider is not + /// registered or does not conform. The view model catches this and + /// proceeds straight to `fetchQuota`, so non-conforming providers are + /// unaffected. public func proactiveRefresh(for providerId: String) async throws { guard let provider = providers[providerId] else { throw ProviderSetupError.notSupported diff --git a/Sources/Core/QuotaFormatting.swift b/Sources/Core/QuotaFormatting.swift index b79d3cc..79162ac 100644 --- a/Sources/Core/QuotaFormatting.swift +++ b/Sources/Core/QuotaFormatting.swift @@ -1,18 +1,10 @@ import Foundation -/// Shared formatting helpers so headline countdowns and UI labels use -/// one identical, localized format (providers 01 AC5/AC7). -/// -/// Time phrases build on `Date.RelativeFormatStyle`, which the OS -/// localizes automatically. +/// Shared formatting so headline countdowns and UI labels use one identical, +/// localized format. Time phrases build on `Date.RelativeFormatStyle`, which +/// the OS localizes automatically. public enum QuotaFormatting { - /// Returns a localized countdown string for a future reset date. - /// - /// Uses `Date.RelativeFormatStyle` so the duration portion ("in 3 hours", - /// "in 45 minutes") is automatically localized by the OS. The wrapping - /// "resets …" prefix is also localized via `String(localized:)`. - /// - /// If the date is in the past, returns a localized "resetting…" fallback. + /// Returns a localized "resetting…" fallback when `resetDate` is in the past. public static func countdown(to resetDate: Date) -> String { guard resetDate > Date() else { return String(localized: "resetting…") diff --git a/Sources/Core/VintageMacIcon.swift b/Sources/Core/VintageMacIcon.swift index a12365a..a479f74 100644 --- a/Sources/Core/VintageMacIcon.swift +++ b/Sources/Core/VintageMacIcon.swift @@ -1,9 +1,8 @@ import Foundation -/// Persists the user's opt-in Vintage Mac menu-bar appearance preference. public enum VintageMacIcon { - /// `nonisolated(unsafe)`: production never mutates this after startup; only - /// the test-injection API writes, and XCTest runs serially. + // `nonisolated(unsafe)`: production never mutates after startup; only the + // test-injection API writes, and XCTest runs serially. private nonisolated(unsafe) static var defaults: UserDefaults = .standard public static var isEnabled: Bool { diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeLocator.swift b/Sources/Providers/ClaudeCode/ClaudeCodeLocator.swift index d09534b..bee8e14 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeLocator.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeLocator.swift @@ -1,9 +1,5 @@ import Foundation -/// Resolves the absolute path to the `claude` binary on this machine -/// (providers 02 AC1). Checks PATH directories first, then a fixed list -/// of known install locations. Never throws — a missing binary returns -/// `nil` rather than an error. public struct ClaudeCodeLocator: Sendable { private let injected: Injected? @@ -12,12 +8,10 @@ public struct ClaudeCodeLocator: Sendable { case notFound } - /// Production initializer — does real filesystem resolution. public init() { injected = nil } - /// Test-only: returns `path` when given, or `nil` for `.notFound`. init(injectedPath: String?) { if let path = injectedPath { injected = .found(path) @@ -26,8 +20,6 @@ public struct ClaudeCodeLocator: Sendable { } } - /// Known install directories checked after PATH lookup fails - /// (providers 02 AC1). private static let knownDirs: [String] = [ "~/.local/bin", "~/.claude/local", @@ -37,18 +29,13 @@ public struct ClaudeCodeLocator: Sendable { "~/.npm-global/bin", ] - /// Returns the absolute path to the first `claude` binary found, or - /// `nil` when the binary is not installed in any searched location. public func resolve() -> String? { - // Test mode: return the injected value immediately. switch injected { case let .found(path): return path case .notFound: return nil - case .none: break // fall through to real resolution + case .none: break } - // 1. PATH — resolved via FileManager + /usr/bin/env, not a shell - // (providers 02 AC1). if let pathDirs = pathDirectories() { for dir in pathDirs { let candidate = (dir as NSString).appendingPathComponent("claude") @@ -58,7 +45,6 @@ public struct ClaudeCodeLocator: Sendable { } } - // 2. Known install directories (providers 02 AC1). for dir in Self.knownDirs { let expanded = (dir as NSString).expandingTildeInPath let candidate = (expanded as NSString).appendingPathComponent("claude") @@ -70,7 +56,7 @@ public struct ClaudeCodeLocator: Sendable { return nil } - // MARK: - Internal helpers (testable) + // MARK: - Internal helpers func pathDirectories() -> [String]? { guard let path = ProcessInfo.processInfo.environment["PATH"] else { diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift b/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift index ef767e5..3e8bf69 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift @@ -3,8 +3,6 @@ import Foundation // MARK: - Diagnostic logging -/// Lightweight stderr logger — diagnostic only. Mirrors the pattern in -/// ZAIProvider so both providers log consistently. enum ClaudeCodeLog { static func log(_ message: @autoclosure () -> String) { FileHandle.standardError.write( @@ -16,10 +14,7 @@ enum ClaudeCodeLog { // MARK: - Error public enum ClaudeCodeError: Error, Equatable, Sendable { - /// The binary is not installed anywhere the locator checks (providers 02 AC1). case binaryNotFound - /// The registry routed `.apiKey` auth — contract-integrity violation - /// (providers 02 AC2). case internalInconsistency public static func == (lhs: ClaudeCodeError, rhs: ClaudeCodeError) -> Bool { @@ -42,7 +37,7 @@ extension ClaudeCodeError: LocalizedError { } } -// MARK: - Provider (providers 02) +// MARK: - Provider public struct ClaudeCodeProvider: AIProvider { // MARK: - AIProvider metadata @@ -53,7 +48,7 @@ public struct ClaudeCodeProvider: AIProvider { public static let providerDescription = String( localized: "Monitor Claude Pro/Max subscription usage" ) - /// Placeholder — this provider never makes network calls (providers 02 AC2). + /// Placeholder — this provider never makes network calls. public static let baseURL = URL(string: "https://api.anthropic.com")! public static let authShape: ProviderAuth.Shape = .apiKeyFree public static let setupHelp: ProviderSetupHelp? = ProviderSetupHelp( @@ -61,8 +56,6 @@ public struct ClaudeCodeProvider: AIProvider { url: URL(string: "https://docs.claude.com/en/docs/claude-code/overview")! ) - /// Cache freshness threshold before `isStale` flips to `true` - /// (providers 02 AC10). static let freshnessThreshold: TimeInterval = 3600 // MARK: - Dependencies @@ -84,10 +77,8 @@ public struct ClaudeCodeProvider: AIProvider { self.refresher = refresher } - // MARK: - Configuration (providers 02 AC3, core 03 AC5/AC6) + // MARK: - Configuration - /// Returns `true` when the `claude` binary is locatable and the helper - /// is installed (providers 02 AC1, AC7, AC8). public func isConfigured() -> Bool { let binaryPath = locator.resolve() let helperInstalled = installer.isHelperInstalled() @@ -96,8 +87,6 @@ public struct ClaudeCodeProvider: AIProvider { return binaryPath != nil && helperInstalled } - /// Reports the current setup state so the Settings row can show why - /// the provider is not ready (core 03 AC6). public func currentSetupState() async -> ProviderState? { if locator.resolve() == nil { return .setup(String(localized: "Claude Code not found")) @@ -129,17 +118,12 @@ public struct ClaudeCodeProvider: AIProvider { return nil } - // MARK: - Helper management (ui 05 AC4/AC5) + // MARK: - Helper management - /// Returns `true` when the `claude` binary is present but the helper is - /// not yet installed — the Settings row uses this to decide whether to - /// show the "Install Helper" button (ui 05 AC3/AC4). public func canInstallHelper() -> Bool { locator.resolve() != nil && !installer.isHelperInstalled() } - /// Compiles the helper binary and chains it into - /// `~/.claude/settings.json` (providers 02 AC7, ui 05 AC4). public func installHelper() async throws { guard let sourceURL = helperSourceURL else { ClaudeCodeLog.log("installHelper: helper source not found in bundle") @@ -153,9 +137,6 @@ public struct ClaudeCodeProvider: AIProvider { ClaudeCodeLog.log("installHelper: install ok, helperInstalled=\(installer.isHelperInstalled())") } - /// Removes the helper binary, unwraps the chain from - /// `~/.claude/settings.json`, and deletes the cache file - /// (providers 02 AC11, ui 05 AC5). public func removeHelper() async throws { try installer.uninstall() } @@ -167,13 +148,12 @@ public struct ClaudeCodeProvider: AIProvider { ) } - // MARK: - Quota fetch (providers 02 AC2, AC4–AC6, AC10) + // MARK: - Quota fetch public func fetchQuota( auth: ProviderAuth, baseURL _: URL ) async throws -> ProviderQuota { - // Only .apiKeyFree should reach us (providers 02 AC2). guard case .apiKeyFree = auth else { ClaudeCodeLog.log("fetchQuota: rejected non-apiKeyFree auth") throw ClaudeCodeError.internalInconsistency @@ -181,11 +161,7 @@ public struct ClaudeCodeProvider: AIProvider { ClaudeCodeLog.log("fetchQuota: start configured=\(isConfigured())") - // Read the cache file — this provider never touches the network - // (providers 02 AC4). guard let cache = cacheStore.read() else { - // No cache file yet: the provider is configured but has no data. - // Surface as a data-level error per (providers 02 AC10). ClaudeCodeLog.log("fetchQuota: no cache — returning No data") return ProviderQuota( providerId: Self.providerId, @@ -206,17 +182,15 @@ public struct ClaudeCodeProvider: AIProvider { return quota } - // MARK: - Mapping (providers 02 AC5, AC5b, AC6) + // MARK: - Mapping private func map(cache: StatuslineCache) -> ProviderQuota { var lines: [UsageLine] = [] - // Map five-hour window when present (providers 02 AC5). if let fiveHour = cache.rateLimits?.fiveHour { lines.append(usageLine(label: String(localized: "5-hour window"), window: fiveHour)) } - // Map seven-day window when present (providers 02 AC5). if let sevenDay = cache.rateLimits?.sevenDay { lines.append(usageLine(label: String(localized: "Weekly"), window: sevenDay)) } @@ -228,8 +202,6 @@ public struct ClaudeCodeProvider: AIProvider { let lastUpdated = Date(timeIntervalSince1970: cache.writtenAt) - // Staleness: true when written_at is older than the freshness - // threshold (providers 02 AC5b, AC10). let age = Date().timeIntervalSince(lastUpdated) let isStale = age > Self.freshnessThreshold @@ -243,8 +215,6 @@ public struct ClaudeCodeProvider: AIProvider { ) } - /// Builds one usage line for a window: a percentage (rendered as a bar - /// when present) and a reset countdown (when the reset time is known). private func usageLine(label: String, window: Window) -> UsageLine { UsageLine( label: label, @@ -253,11 +223,6 @@ public struct ClaudeCodeProvider: AIProvider { ) } - /// Builds the headline using 5-hour → weekly priority - /// (providers 02 AC6), reusing the shared `QuotaFormatting.countdown(to:)` - /// helper so the countdown phrase is identical across providers - /// (providers 01 AC5): `"35% · resets in 4 hours"`. Falls back to the - /// countdown alone if a window somehow has a reset but no percentage. private func computeHeadline( fiveHour: Window?, sevenDay: Window? @@ -282,15 +247,9 @@ public struct ClaudeCodeProvider: AIProvider { } } -// MARK: - ProactiveRefreshable (providers 03 AC3) +// MARK: - ProactiveRefreshable extension ClaudeCodeProvider: ProactiveRefreshable { - /// Triggers a window-less `claude -p` spawn via the refresher before the - /// next `fetchQuota` call re-reads the cache (providers 03 AC3). - /// - /// The view model only invokes this on a manual Refresh click — the - /// auto-refresh loop still goes straight to `fetchQuota` and never - /// spawns `claude`. public func proactiveRefresh() async throws { ClaudeCodeLog.log("proactiveRefresh: delegating to refresher") try await refresher.refresh() diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+Parse.swift b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+Parse.swift index 6fc4e06..63519c8 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+Parse.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+Parse.swift @@ -1,28 +1,18 @@ import Foundation -// MARK: - Parse & write (providers 03) - -/// Turns the `claude -p "/usage" --output-format json` output into cache -/// windows and writes them. Split out of `ClaudeCodeRefresher` so each file -/// stays focused: the main type owns the process lifecycle, this extension -/// owns the text → cache mapping. -/// -/// The `/usage` command prints (inside the JSON `result` field) lines like: -/// ``` -/// Current session: 77% used · resets Jul 21 at 12:59am (Europe/Berlin) -/// Current week (all models): 37% used · resets Jul 24 at 5:59am (Europe/Berlin) -/// ``` +// MARK: - Parse & write + +/// `/usage` prints lines like: +/// Current session: 77% used · resets Jul 21 at 12:59am (Europe/Berlin) +/// Current week (all models): 37% used · resets Jul 24 at 5:59am (Europe/Berlin) /// "Current session" is the 5-hour window; "Current week (all models)" is the -/// 7-day window. The parse is English-oriented (the CLI's default); if a line -/// doesn't match, that window is simply skipped rather than guessed. +/// 7-day window. Lines that don't match are skipped rather than guessed. extension ClaudeCodeRefresher { - /// Which cache slot a parsed line maps to. enum WindowSlot { case fiveHour case sevenDay } - /// One usage window parsed out of the `/usage` text, tagged with its slot. struct ParsedWindow { let slot: WindowSlot let window: Window @@ -33,16 +23,11 @@ extension ClaudeCodeRefresher { "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, ] - /// Decodes the `/usage` JSON envelope. The CLI emits a single object whose - /// `result` field carries the usage text; the wrapper replaces - /// `JSONSerialization` + `[String: Any]` so `Sources/` stays free of the - /// `Any` type (ci 04 AC7). + /// The CLI emits a single object whose `result` field carries the usage text. private struct UsageEnvelope: Decodable { let result: String } - /// Decodes the `/usage` JSON, pulls the `result` text, and parses the - /// session (5-hour) and week (7-day) lines out of it. static func parseUsageWindows(fromUsageJSON data: Data) -> [ParsedWindow] { guard let envelope = try? JSONDecoder().decode(UsageEnvelope.self, from: data) else { return [] @@ -59,9 +44,6 @@ extension ClaudeCodeRefresher { return parsed } - /// Parses a single `": NN% used · resets …"` line into a `Window`. - /// Returns `nil` when the percentage can't be found; a missing/unparseable - /// reset phrase yields a window with a percentage but no `resetsAt`. static func parseUsageLine(in text: String, prefix: String) -> Window? { let escaped = NSRegularExpression.escapedPattern(for: prefix) guard let match = firstMatch(escaped + #":\s*(\d+)%\s*used([^\n]*)"#, in: text), @@ -76,10 +58,9 @@ extension ClaudeCodeRefresher { ) } - /// Parses a reset phrase such as `"Jul 21 at 12:59am (Europe/Berlin)"` or - /// `"Jul 21 at 1am (Europe/Berlin)"` into a Unix timestamp. The year is - /// absent from the text, so it's inferred as the nearest occurrence (this - /// year, rolled to next year if that would already be well in the past). + /// The year is absent from the reset text, so it's inferred as the nearest + /// occurrence (this year, rolled to next year if that would already be in + /// the past). Accepts both `12:59am` and `1am` styles. static func parseResetPhrase(_ phrase: String) -> TimeInterval? { let pattern = #"([A-Za-z]{3,})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)"# guard let match = firstMatch(pattern, in: phrase), @@ -93,7 +74,6 @@ extension ClaudeCodeRefresher { else { return nil } let minute = match[4].flatMap { Int($0) } ?? 0 - // 12-hour → 24-hour. if meridiem == "pm", hour != 12 { hour += 12 } @@ -114,7 +94,7 @@ extension ClaudeCodeRefresher { components.year = currentYear guard let candidate = calendar.date(from: components) else { return nil } - // A reset that already passed (allowing a 2-day slack for clock skew / + // A reset that already passed (allowing 2-day slack for clock skew / // month boundaries) must belong to next year. if candidate < now.addingTimeInterval(-2 * 86400) { components.year = currentYear + 1 @@ -125,8 +105,6 @@ extension ClaudeCodeRefresher { return candidate.timeIntervalSince1970 } - /// Runs `pattern` against `text` and returns its capture groups (index 0 is - /// the whole match). A group that didn't participate is `nil`. private static func firstMatch(_ pattern: String, in text: String) -> [String?]? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil @@ -144,10 +122,9 @@ extension ClaudeCodeRefresher { } } - /// Merges the freshly parsed windows over whatever the cache already holds - /// and writes the result. Each parsed window *replaces* its slot; windows - /// this run didn't report are carried over from the existing cache (a - /// defensive measure — `/usage` normally prints both windows together). + /// Each parsed window replaces its slot; windows this run didn't report are + /// carried over from the existing cache as a defensive measure (`/usage` + /// normally prints both windows together). static func mergeAndWriteCache( windows: [ParsedWindow], into cacheStore: StatuslineCacheStore diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+WorkingDirectory.swift b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+WorkingDirectory.swift index f703edf..1eb64b4 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+WorkingDirectory.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher+WorkingDirectory.swift @@ -1,19 +1,14 @@ import Foundation extension ClaudeCodeRefresher { - // MARK: - Working directory (providers 06 AC1) + // MARK: - Working directory - /// Creates a Filbert-owned directory below - /// `FileManager.default.temporaryDirectory` for the `claude` child to run - /// in. Returns `nil` if creation fails, in which case the caller aborts - /// the spawn and leaves the cache untouched. - /// - /// The directory is intentionally *not* the user's home, Documents, - /// Desktop, Downloads, Music, the app's source checkout, or any - /// user-selected project: `temporaryDirectory` is per-user but lives - /// outside TCC-protected locations, so a child spawned here cannot reach - /// those locations through CWD/parent-walk discovery at startup - /// (providers 06 AC1). + /// `temporaryDirectory` is per-user but lives outside TCC-protected + /// locations, so a child spawned here cannot reach home, Documents, + /// Desktop, Downloads, etc. through CWD/parent-walk discovery at startup. + /// Returns `nil` if creation fails; the caller then aborts the spawn and + /// leaves the cache untouched — never fall back to inheriting the + /// parent's CWD. static func makeDefaultWorkingDirectory() -> URL? { let url = FileManager.default.temporaryDirectory .appendingPathComponent("filbert-claude-code-spawn", isDirectory: true) diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher.swift b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher.swift index 55dc287..10c7085 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeRefresher.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeRefresher.swift @@ -3,8 +3,6 @@ import Foundation // MARK: - Diagnostic logging -/// Lightweight stderr logger shared by the refresher. Mirrors the pattern in -/// `ClaudeCodeLog` so all Claude Code diagnostics land in the same shape. enum ClaudeCodeRefresherLog { static func log(_ message: @autoclosure () -> String) { FileHandle.standardError.write( @@ -13,15 +11,11 @@ enum ClaudeCodeRefresherLog { } } -// MARK: - Errors (providers 03 AC2) +// MARK: - Errors -/// Errors thrown by `ClaudeCodeRefresher.refresh()`. All of them are -/// diagnostic-only: the provider swallows them and reads whatever cache -/// exists (providers 03 AC3), so they never surface to the UI as fetch -/// failures. +/// All errors are diagnostic-only: the provider swallows them and reads +/// whatever cache exists, so they never surface to the UI as fetch failures. public enum ClaudeCodeRefresherError: Error, Equatable, Sendable { - /// The `claude` binary could not be resolved via `ClaudeCodeLocator` - /// (providers 03 AC1, mirroring providers 02 AC1). case binaryNotFound public static func == (lhs: ClaudeCodeRefresherError, rhs: ClaudeCodeRefresherError) -> Bool { @@ -31,68 +25,39 @@ public enum ClaudeCodeRefresherError: Error, Equatable, Sendable { } } -// MARK: - Refresher (providers 03 AC1, AC2, AC4, AC5) +// MARK: - Refresher -/// Spawns `claude -p "/usage"` on demand, parses the usage percentages and -/// reset times out of that command's text output, and writes the shared -/// cache — then terminates the process after a bounded wait (providers 03). +/// Spawns `claude -p "/usage"` headlessly and parses the same `NN% used · +/// resets …` figures the TUI shows, then writes the cache. The TUI statusline +/// helper only fires inside Claude Code's interactive session; this path makes +/// the Refresh button work for users who drive Claude Code through an editor +/// (e.g. Zed) and never open the TUI. /// -/// This is the headless refresh path. Unlike the statusline helper -/// (providers 02), which only fires inside Claude Code's interactive TUI, a -/// `claude -p` run never renders a status line — so the refresher cannot rely -/// on the helper being invoked. Instead it runs the `/usage` command (which -/// prints the same `NN% used · resets …` figures the TUI shows) and writes -/// the cache itself. This makes the Refresh button work for users who drive -/// Claude Code through an editor (e.g. Zed) and never open the TUI. -/// -/// The refresher is an `actor` because the debounce timestamp and the -/// in-flight task slot both need serialized mutation (providers 03 AC4). -/// All spawn work happens inside a child `Task` so the actor itself is never +/// An `actor` serializes the debounce timestamp and the in-flight task slot; +/// all spawn work happens inside a child `Task` so the actor itself is never /// blocked by `Process.run` / `waitUntilExit`. public actor ClaudeCodeRefresher { - /// Maximum wall-clock seconds to wait for `claude -p` to exit on its own - /// before terminating it (providers 03 AC2). Haiku + `--max-turns 1` - /// typically finishes in 1–3s; this is a safety net for network hiccups. static let spawnTimeoutSeconds: TimeInterval = 30 - /// Grace period between `SIGTERM` and `SIGKILL` (providers 03 AC2). static let terminateGraceSeconds: TimeInterval = 2 - /// Minimum seconds between two spawns (providers 03 AC4). Matches the - /// read-debounce window in (providers 02 AC9) so a spawn and a re-read - /// share the same cadence. static let spawnDebounceSeconds: TimeInterval = 60 - /// Argv passed to the `claude` binary (providers 03 AC1, revised by - /// providers 06 AC2). Notes on order and choice: - /// - `-p "/usage"` runs the built-in usage command non-interactively; - /// its text output carries the session (5-hour) and week (7-day) - /// percentages and reset times. - /// - `--output-format json` wraps that text in a single JSON object so - /// we read it cleanly from the `result` field (no `--verbose` needed, - /// unlike `stream-json`). - /// - `--model haiku` / `--max-turns 1` bound the work in case `/usage` - /// ever triggers a model turn; `/usage` is otherwise model-free. - /// - `--no-session-persistence` avoids a throwaway session on disk. - /// - `--tools ""` disables every tool (CLI: `""` means "no tools"). It - /// is variadic, so it must be followed by another *flag* — never by - /// the positional prompt — otherwise it swallows the prompt and - /// `claude` errors with "Input must be provided … when using --print". - /// Keeping `-p "/usage"` last makes the positional prompt unambiguous. - /// - `--safe-mode`, `--strict-mcp-config`, and `--no-chrome` suppress - /// Claude Code's startup discovery surface — CLAUDE.md files, skills, - /// plugins, hooks, MCP servers, custom commands/agents, output styles, - /// status-line commands, LSP servers, auto-memory, and Chrome init — - /// so a child that inherits an inert working directory cannot probe - /// macOS-protected user locations during startup (providers 06 AC1, - /// AC2). These are *startup-isolation* flags, not Claude tool- - /// permission modes: they neither grant, deny, nor suppress a macOS - /// TCC decision, and `--permission-mode bypassPermissions` / - /// `--dangerously-skip-permissions` are deliberately absent - /// (providers 06 AC3). `--strict-mcp-config` is passed without a - /// sibling `--mcp-config`, so no user, project, or local MCP server - /// is loaded. `--bare` is also deliberately absent: it would disable - /// the OAuth/Keychain login the refresh must reuse. + /// Notes on the non-obvious flags: + /// - `--tools ""` is variadic, so it must be followed by another *flag* — + /// never by the positional prompt — otherwise it swallows the prompt + /// and `claude` errors with "Input must be provided … when using + /// --print". Keeping `-p "/usage"` last makes the positional prompt + /// unambiguous. + /// - `--strict-mcp-config` is passed without a sibling `--mcp-config`, + /// so no user, project, or local MCP server is loaded. + /// - `--safe-mode` and `--no-chrome` suppress Claude Code's startup + /// discovery surface (CLAUDE.md walk, skills, plugins, hooks, MCP + /// servers, output styles, status-line commands, …) so a child that + /// inherits an inert working directory cannot probe macOS-protected + /// user locations at startup. + /// - `--bare` is deliberately absent: it would disable the OAuth/Keychain + /// login the refresh must reuse. static let spawnArguments: [String] = [ "--model", "haiku", "--max-turns", "1", @@ -112,17 +77,10 @@ public actor ClaudeCodeRefresher { private let spawnDebounce: TimeInterval private let workingDirectoryProvider: @Sendable () -> URL? - /// Timestamp of the most recent spawn *attempt* — successful or not — - /// used to suppress follow-up clicks within the debounce window - /// (providers 03 AC4). Cleared only by app restart; not persisted. private var lastSpawnAt: Date? - /// Non-nil while a spawn task is in flight. Concurrent callers await this - /// task instead of starting a second `claude` process (providers 03 AC4). private var inFlightTask: Task? - /// Production initializer — uses the real locator for binary resolution, - /// the shared cache store, and the production timeout / debounce values. public init( locator: ClaudeCodeLocator = ClaudeCodeLocator(), cacheStore: StatuslineCacheStore = StatuslineCacheStore() @@ -135,9 +93,6 @@ public actor ClaudeCodeRefresher { workingDirectoryProvider = { @Sendable in Self.makeDefaultWorkingDirectory() } } - /// Test-only initializer that overrides the cache store and the timeout / - /// debounce windows. Used by `ClaudeCodeRefresherTests` to point the cache - /// at a temp file and keep the timeout test fast. init( locator: ClaudeCodeLocator, cacheStore: StatuslineCacheStore = StatuslineCacheStore(), @@ -156,30 +111,18 @@ public actor ClaudeCodeRefresher { self.workingDirectoryProvider = workingDirectoryProvider } - // MARK: - Public entry point (providers 03 AC1, AC2, AC4) + // MARK: - Public entry point - /// Triggers a spawn unless one is in flight or the debounce window has - /// not yet elapsed. Always returns without throwing on spawn failure — - /// the provider reads whatever cache exists regardless (providers 03 AC3). - /// - /// The only error this surfaces is `binaryNotFound`, which the provider - /// maps to "No data" alongside any prior cache content. All other spawn - /// failures (non-zero exit, timeout, SIGKILL) are diagnostic-only. public func refresh() async throws { - // Coalesce first: if a spawn is in flight, await it instead of - // starting a second `claude` process (providers 03 AC4). This check - // must come before the debounce check, otherwise a click that arrives - // while a spawn is running would short-circuit and miss the chance - // to await the in-flight result. + // Coalesce before debounce: if a click arrives while a spawn is in + // flight, it should await that result — checking debounce first would + // short-circuit and miss the in-flight result. if let inFlightTask { ClaudeCodeRefresherLog.log("refresh: awaiting in-flight spawn") try await inFlightTask.value return } - // Debounce: a spawn attempt within the window returns immediately - // (providers 03 AC4). The cache read in AC3 still happens on the - // caller side, so the user sees the most recent data we have. if let lastSpawnAt { let elapsed = Date().timeIntervalSince(lastSpawnAt) if elapsed < spawnDebounce { @@ -191,7 +134,7 @@ public actor ClaudeCodeRefresher { } // Record the attempt *before* spawning so a failure still suppresses - // follow-up clicks within the debounce window (providers 03 AC4). + // follow-up clicks within the debounce window. lastSpawnAt = Date() let task = Task { [locator, cacheStore, spawnTimeout, terminateGrace, workingDirectoryProvider] in @@ -210,15 +153,11 @@ public actor ClaudeCodeRefresher { try await task.value } - // MARK: - Spawn lifecycle (providers 03 AC1, AC2) + // MARK: - Spawn lifecycle - /// Resolves the binary, spawns it with the documented argv, waits for - /// exit or the bounded timeout, then parses the captured stdout and writes - /// the cache. Non-timeout failures are logged and swallowed; only - /// `binaryNotFound` is surfaced to the caller because nothing can be - /// refreshed without the binary. When stdout yields no `rate_limit_event` - /// (spawn failed, logged out, CLI drift), the cache is left untouched so a - /// failed refresh never clobbers previously good data (providers 03 AC6). + /// When stdout yields no usage figures (spawn failed, logged out, CLI + /// drift), the cache is left untouched so a failed refresh never clobbers + /// previously good data. Only `binaryNotFound` is surfaced. private static func runSpawnOnce( locator: ClaudeCodeLocator, cacheStore: StatuslineCacheStore, @@ -231,10 +170,9 @@ public actor ClaudeCodeRefresher { throw ClaudeCodeRefresherError.binaryNotFound } - // Spawn inside a dedicated, inert working directory so Claude Code's - // startup CWD discovery (CLAUDE.md walk, project state) does not touch - // macOS-protected user locations (providers 06 AC1). If the directory - // cannot be created, abort this attempt and leave the cache untouched — + // Spawn inside an inert working directory so Claude Code's startup + // CWD discovery does not touch macOS-protected user locations. If the + // directory cannot be created, abort and leave the cache untouched — // never fall back to inheriting the parent's CWD. guard let workingDirectoryURL = workingDirectoryProvider() else { ClaudeCodeRefresherLog.log( @@ -249,9 +187,8 @@ public actor ClaudeCodeRefresher { process.executableURL = URL(fileURLWithPath: binaryPath) process.arguments = spawnArguments process.currentDirectoryURL = workingDirectoryURL - // Capture stdout — that's where the `rate_limit_event` lands in - // stream-json mode. stderr is discarded: we never surface the child's - // diagnostics, and dropping it keeps a GUI menu-bar app quiet. + // stderr is discarded: we never surface the child's diagnostics, and + // dropping it keeps a GUI menu-bar app quiet. let stdoutPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = FileHandle.nullDevice @@ -296,10 +233,6 @@ public actor ClaudeCodeRefresher { mergeAndWriteCache(windows: windows, into: cacheStore) } - /// Awaits process exit via `terminationHandler`, with a bounded timeout - /// running in parallel. On timeout, escalates from `SIGTERM` to `SIGKILL` - /// after the grace period (providers 03 AC2). - /// /// Uses `terminationHandler` + a continuation rather than `waitUntilExit`, /// because the latter is a blocking sync call that task-group cancellation /// cannot interrupt — racing it against `Task.sleep` inside a group would @@ -322,7 +255,6 @@ public actor ClaudeCodeRefresher { // `interrupt()` sends SIGTERM on macOS (Process docs). process.interrupt() - // Grace period before escalating to SIGKILL. let graceStart = Date() let graceDeadline = graceStart.addingTimeInterval(terminateGrace) while process.isRunning, Date() < graceDeadline { @@ -354,18 +286,12 @@ public actor ClaudeCodeRefresher { ) } - // MARK: - Environment (providers 03 AC1) + // MARK: - Environment - /// Builds the environment for the spawned `claude` process. We inherit - /// the parent environment and ensure the binary's own directory is on - /// `PATH` so Claude Code's own subprocess lookups still succeed - /// (providers 03 AC1). private static func makeSpawnEnvironment(forBinaryAt binaryPath: String) -> [String: String] { var environment = ProcessInfo.processInfo.environment let binaryDir = (binaryPath as NSString).deletingLastPathComponent let currentPath = environment["PATH"] ?? "" - // Prepend so the resolved binary's directory takes precedence, - // matching how Claude Code itself was located. environment["PATH"] = "\(binaryDir):\(currentPath)" return environment } diff --git a/Sources/Providers/ClaudeCode/Resources/statusline_helper.swift b/Sources/Providers/ClaudeCode/Resources/statusline_helper.swift index 03f0bbe..4178eba 100644 --- a/Sources/Providers/ClaudeCode/Resources/statusline_helper.swift +++ b/Sources/Providers/ClaudeCode/Resources/statusline_helper.swift @@ -1,25 +1,12 @@ #!/usr/bin/env swift import Foundation -// Reads Claude Code statusline JSON from stdin, extracts `rate_limits`, -// and writes an atomic cache for the filbert menu bar app. -// -// This source is compiled at install time with `swiftc -O` so the helper -// binary has near-zero cold-start latency when Claude Code spawns it on -// every statusline update (providers 02 Plan §5). +// Compiled at install time with `swiftc -O` so the helper binary has +// near-zero cold-start latency when Claude Code spawns it on every statusline +// update. // MARK: - Codable shapes -// -// The input and output JSON shapes are mirrored as Codable structs instead of -// `[String: Any]` + `JSONSerialization` so this file stays free of the `Any` -// type (ci 04 AC7). Field names and nesting match the shape the cache reader -// (`StatuslineCacheStore`) decodes. - -/// One window inside the statusline payload. Fields are optional so a partial -/// payload (missing `used_percentage` or `resets_at`) still decodes; the -/// defaults are applied when building the cache (matching the previous -/// `as? Double ?? 0` behaviour). struct StatuslineWindow: Decodable { let usedPercentage: Double? let resetsAt: Double? @@ -48,9 +35,8 @@ struct StatuslineInput: Decodable { } } -/// A non-optional window in the cache. The previous implementation defaulted -/// missing values to `0`, and the cache reader treats `0` as "unknown" via the -/// optional fields on its own model, so writing `0` here preserves that. +/// Non-optional in the cache. Missing values default to `0`, which the cache +/// reader treats as "unknown" via the optional fields on its own model. struct CacheWindow: Encodable { let usedPercentage: Double let resetsAt: Double @@ -96,22 +82,17 @@ let rawInput = FileHandle.standardInput.readDataToEndOfFile() let writtenAt = Date().timeIntervalSince1970 -// Diagnostic log: every invocation lands here so we can confirm Claude -// Code is actually spawning the helper. Writes to a sibling file next to -// the cache so it never interferes with stdout (which Claude Code captures). +// Writes to a sibling file next to the cache so it never interferes with +// stdout (which Claude Code captures). let debugLogURL = cacheDir.appendingPathComponent("claude-code.helper.log") let debugLine = "\(Date()) invoked pid=\(ProcessInfo.processInfo.processIdentifier) bytes=\(rawInput.count)\n" appendDiagnosticLine(debugLine, to: debugLogURL) -/// Decode the statusline payload, tolerating empty/absent/unparseable input. /// A failed decode writes a cache with no `rate_limits` so the provider -/// surfaces "No data" rather than silently clearing the last known state -/// (providers 02 AC10). +/// surfaces "No data" rather than silently clearing the last known state. let input = (try? JSONDecoder().decode(StatuslineInput.self, from: rawInput)) ?? StatuslineInput(rateLimits: nil) -/// Convert the decoded optional windows into non-optional cache windows, -/// applying the historical `?? 0` default for missing fields. let fiveHour: CacheWindow? = input.rateLimits?.fiveHour.map { CacheWindow( usedPercentage: $0.usedPercentage ?? 0, @@ -126,15 +107,14 @@ let sevenDay: CacheWindow? = input.rateLimits?.sevenDay.map { ) } -/// Match the previous "only write rate_limits when at least one window is -/// present" behaviour: an empty `rate_limits` object is never written. +/// An empty `rate_limits` object is never written. let rateLimits: CacheRateLimits? = (fiveHour != nil || sevenDay != nil) ? CacheRateLimits(fiveHour: fiveHour, sevenDay: sevenDay) : nil writeCache(CachePayload(writtenAt: writtenAt, rateLimits: rateLimits)) -// MARK: - Atomic write (providers 02 AC7) +// MARK: - Atomic write func writeCache(_ payload: CachePayload) { try? FileManager.default.createDirectory( @@ -159,9 +139,8 @@ func writeCache(_ payload: CachePayload) { // MARK: - Diagnostic log -/// Appends `line` to `logURL`, creating the file on first write. Best-effort: -/// any I/O failure is silently dropped so diagnostics never break the cache -/// write path. +// Best-effort: any I/O failure is silently dropped so diagnostics never +// break the cache write path. func appendDiagnosticLine(_ line: String, to logURL: URL) { guard let data = line.data(using: .utf8) else { return } if FileManager.default.fileExists(atPath: logURL.path) { diff --git a/Sources/Providers/ClaudeCode/StatuslineCacheStore.swift b/Sources/Providers/ClaudeCode/StatuslineCacheStore.swift index eaf5cbc..c649a3d 100644 --- a/Sources/Providers/ClaudeCode/StatuslineCacheStore.swift +++ b/Sources/Providers/ClaudeCode/StatuslineCacheStore.swift @@ -1,25 +1,19 @@ import Foundation -// MARK: - Cache path (providers 02 AC4) +// MARK: - Cache path -/// `~/.cache/filbert/claude-code.json` — the single source of truth -/// for Claude Code usage data (providers 02 AC4). public let claudeCodeCacheFileURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cache") .appendingPathComponent("filbert") .appendingPathComponent("claude-code.json") -// MARK: - Cache model (providers 02 AC5) +// MARK: - Cache model -/// Full cache payload the helper script writes and the provider reads. /// Mirrors the `rate_limits` shape documented at /// https://code.claude.com/docs/en/statusline as of 2026-07. struct StatuslineCache: Codable { - /// Unix epoch seconds of when the helper wrote this cache file. let writtenAt: TimeInterval - /// The `rate_limits` subtree from Claude Code's statusline JSON. - /// `nil` when the subscription does not carry rate-limit data - /// (free-tier or brand-new session) (providers 02 AC5). + /// `nil` for free-tier or brand-new sessions that carry no rate-limit data. let rateLimits: RateLimits? enum CodingKeys: String, CodingKey { @@ -28,7 +22,6 @@ struct StatuslineCache: Codable { } } -/// The two sliding windows Claude Code reports for Pro/Max subscribers. struct RateLimits: Codable { let fiveHour: Window? let sevenDay: Window? @@ -39,24 +32,12 @@ struct RateLimits: Codable { } } -/// One usage window: a used-percentage value and a reset timestamp. -/// -/// Two independent sources populate this, and both produce the same shape -/// (providers 03): -/// - The **statusline helper** (providers 02) fires only in Claude Code's -/// interactive TUI and writes `used_percentage` + `resets_at`. -/// - The **refresher** (providers 03) spawns `claude -p "/usage"` -/// headlessly and parses the same `NN% used · resets …` figures out of -/// that command's text — so it works without an interactive session -/// (e.g. for users driving Claude Code through Zed). -/// -/// Both fields are optional so a partial parse (a percentage whose reset -/// phrase we couldn't parse, say) still surfaces what it has rather than -/// dropping the whole window. +/// Both fields are optional so a partial parse (e.g. a percentage whose reset +/// phrase couldn't be parsed) still surfaces what it has rather than dropping +/// the whole window. struct Window: Codable { - /// Percentage of the window consumed (0–100). let usedPercentage: Double? - /// Unix epoch **seconds** when this window resets (providers 02 AC5). + /// Unix epoch **seconds** when this window resets. let resetsAt: TimeInterval? enum CodingKeys: String, CodingKey { @@ -70,13 +51,8 @@ struct Window: Codable { } } -// MARK: - Cache store (providers 02 AC4, AC7) +// MARK: - Cache store -/// Reads (and, in tests, writes) the Claude Code statusline cache. -/// -/// In production the helper script owns writes; the provider only reads. -/// The `write` method exists so tests can seed cache fixtures and verify -/// the atomic-write contract (providers 02 AC7). public struct StatuslineCacheStore: Sendable { private let cacheURL: URL private let fallbackCacheURL: URL? @@ -96,9 +72,7 @@ public struct StatuslineCacheStore: Sendable { self.fallbackCacheURL = fallbackCacheURL } - /// Reads and decodes the cache file. Returns `nil` when the file is - /// absent or unparseable — a missing cache is a data state, not an error - /// (providers 02 AC10). + /// A missing or unparseable cache is a data state, not an error. func read() -> StatuslineCache? { if let cache = read(at: cacheURL) { return cache @@ -128,8 +102,6 @@ public struct StatuslineCacheStore: Sendable { return cache } - /// Writes `cache` atomically via temp-file + rename so a concurrent - /// reader never sees a half-written file (providers 02 AC7). func write(_ cache: StatuslineCache) throws { let dir = cacheURL.deletingLastPathComponent() try FileManager.default.createDirectory( diff --git a/Sources/Providers/ClaudeCode/StatuslineHelperInstaller.swift b/Sources/Providers/ClaudeCode/StatuslineHelperInstaller.swift index c86bdb7..4291b56 100644 --- a/Sources/Providers/ClaudeCode/StatuslineHelperInstaller.swift +++ b/Sources/Providers/ClaudeCode/StatuslineHelperInstaller.swift @@ -1,32 +1,23 @@ import Core import Foundation -// MARK: - Paths (providers 02 Plan §5) +// MARK: - Paths -/// `~/.claude/filbert-statusline` — the compiled helper binary -/// (providers 02 Plan §5). public let claudeHelperDestURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude") .appendingPathComponent("filbert-statusline") -/// `~/.claude/settings.json` — Claude Code's settings file -/// (providers 02 AC8). public let claudeSettingsFileURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude") .appendingPathComponent("settings.json") -// MARK: - Installer error (providers 02 AC8) +// MARK: - Installer error public enum InstallerError: Error, Equatable, Sendable { - /// `swiftc` is not on PATH — install cannot proceed (providers 02 Plan §5). case swiftcNotFound - /// `swiftc` exited non-zero. case compilationFailed(Int32) - /// `settings.json` exists but does not parse as JSON (providers 02 AC8). case unparseableSettings - /// The bundled helper source is missing from the app resources. case helperSourceNotFound - /// The helper or Claude settings did not match the staged installation. case configurationVerificationFailed public static func == (lhs: InstallerError, rhs: InstallerError) -> Bool { @@ -58,26 +49,20 @@ extension InstallerError: LocalizedError { } } -// MARK: - Codable model for ~/.claude/settings.json (ci 04 AC7) +// MARK: - Codable model for ~/.claude/settings.json -// // `settings.json` is user-owned and open-schema: Claude Code accepts arbitrary // sibling keys alongside `statusLine`, and `statusLine` itself may be a bare // string or an object with arbitrary keys (`padding`, `refreshInterval`, …). // These types model the known keys (`statusLine`, `command`, `type`) while // sinking every unknown key into an `extra: [String: AnyJSON]` so a -// read/modify/write round-trip preserves them — without exposing the `Any` -// type (ci 04 AC5/AC6). `AnyJSON` is the open-schema value type from `Core`. +// read/modify/write round-trip preserves them. -/// The `statusLine` value: either a bare command string or a typed object -/// (providers 02 AC8). enum StatusLineValue: Equatable { case string(String) case object(StatusLineObject) } -/// The object form of `statusLine`. `command` and `type` are typed; every -/// other key (`padding`, `refreshInterval`, …) is preserved in `extra`. struct StatusLineObject: Equatable { var command: String? var type: String? @@ -90,8 +75,6 @@ struct StatusLineObject: Equatable { } } -/// The top-level `~/.claude/settings.json` object. `statusLine` is typed; -/// every other top-level key is preserved in `extra`. struct ClaudeSettings: Equatable { var statusLine: StatusLineValue? var extra: [String: AnyJSON] @@ -104,7 +87,6 @@ struct ClaudeSettings: Equatable { // MARK: - Codable round-trip via [String: AnyJSON] -// // `KeyedDecodingContainer` with a fixed `CodingKey` enum silently drops keys // not in the enum, which would lose exactly the open-schema sibling keys this // model exists to preserve. Decoding the whole object as `[String: AnyJSON]` @@ -181,8 +163,7 @@ extension ClaudeSettings: Codable { } /// Pulls a `String` out of an `AnyJSON` that is expected to hold a string, - /// returning `nil` for any other kind (matches the historical - /// `as? String` tolerance). + /// returning `nil` for any other kind. private static func stringValue(_ value: AnyJSON) -> String? { if case let .string(string) = value { return string @@ -191,16 +172,8 @@ extension ClaudeSettings: Codable { } } -// MARK: - Installer (providers 02 AC7, AC8, AC11) +// MARK: - Installer -/// Manages installation and removal of the statusline helper binary and its -/// integration into Claude Code's `~/.claude/settings.json`. -/// -/// The helper binary reads statusline JSON from stdin, extracts -/// `rate_limits`, and writes an atomic cache file for the provider to read -/// (providers 02 AC7). The installer chains the helper into any existing -/// `statusLine.command` so the user's prior statusline continues to work -/// (providers 02 AC8). public struct StatuslineHelperInstaller: Sendable { private let settingsURL: URL private let helperDestURL: URL @@ -210,8 +183,9 @@ public struct StatuslineHelperInstaller: Sendable { // MARK: - Chain markers - /// Sentinels embedded in the chained shell command so we can detect our - /// own wrapper and extract the original command (providers 02 AC8). + // Sentinels bracketing the original command in the chained shell string, + // so we can detect our own wrapper and extract the original on + // reinstall/uninstall. private static let chainStart = "###FILBERT-CHAIN-START###" private static let chainSep = "###FILBERT-CHAIN-SEPARATOR###" @@ -255,10 +229,8 @@ public struct StatuslineHelperInstaller: Sendable { self.swiftCompilerPath = swiftCompilerPath } - // MARK: - Status (providers 02 AC3) + // MARK: - Status - /// Returns `true` when the compiled helper binary exists and is - /// executable at the expected destination. public func isHelperInstalled() -> Bool { FileManager.default.isExecutableFile(atPath: helperDestURL.path) } @@ -267,13 +239,8 @@ public struct StatuslineHelperInstaller: Sendable { (try? hasLegacyHelperIntegration()) == true } - // MARK: - Install (providers 02 AC7, AC8) + // MARK: - Install - /// Full install: compiles the helper from the bundled Swift source, - /// then chains it into `~/.claude/settings.json`. - /// - /// - Parameter helperSourceURL: The URL of `statusline_helper.swift` in - /// the app bundle. Obtain via `Bundle.module.url(forResource:withExtension:)`. public func install(helperSourceURL: URL) throws { let helperExisted = isHelperInstalled() let settingsBackup = try? Data(contentsOf: settingsURL) @@ -301,37 +268,26 @@ public struct StatuslineHelperInstaller: Sendable { return true } - // MARK: - Uninstall (providers 02 AC11) + // MARK: - Uninstall - /// Removes the helper binary, unwraps any filbert chain from - /// `settings.json`, and deletes the cache file. public func uninstall() throws { - // 1. Unwrap settings.json (providers 02 AC11). try removeFromSettings() - - // 2. Delete the compiled helper binary. try? FileManager.default.removeItem(at: helperDestURL) - - // 3. Delete the cache file so stale data doesn't linger. + // Delete the cache file so stale data doesn't linger. try? FileManager.default.removeItem(at: cacheURL) } // MARK: - Internal (testable entry points) - /// Settings-only install. Assumes the helper binary already exists at - /// `helperDestURL`. Exposed for testing so suites can pre-seed a dummy - /// binary and exercise the settings-manipulation logic in isolation. func installSettingsOnly() throws { try updateSettingsForInstall() } - /// Settings-only uninstall. Exposed for testing so suites can verify - /// chain unwrapping without touching the filesystem for the binary. func uninstallSettingsOnly() throws { try removeFromSettings() } - // MARK: - Compilation (providers 02 Plan §5) + // MARK: - Compilation private func compileHelper(sourceURL: URL) throws { let destDir = helperDestURL.deletingLastPathComponent() @@ -374,9 +330,6 @@ public struct StatuslineHelperInstaller: Sendable { // MARK: - Settings I/O - /// Reads `settingsURL` as a `ClaudeSettings` model, or returns `nil` when - /// the file is absent. Throws `InstallerError.unparseableSettings` when the - /// file exists but is not valid JSON (providers 02 AC8). private func readSettings() throws -> ClaudeSettings? { guard FileManager.default.fileExists(atPath: settingsURL.path) else { return nil @@ -404,10 +357,8 @@ public struct StatuslineHelperInstaller: Sendable { } } - // MARK: - Settings manipulation (providers 02 AC8) + // MARK: - Settings manipulation - /// Extracts the `command` string from a `statusLine` value, which may - /// be a plain string or a `{"command": "..."}` object (providers 02 AC8). private func commandFromStatusLine(_ value: StatusLineValue?) -> String? { switch value { case let .string(string): @@ -437,7 +388,7 @@ public struct StatuslineHelperInstaller: Sendable { } ?? false if existingCommand.contains(Self.chainStart) { // Already chained by us — extract original, re-wrap so a - // reinstall replaces our wrapper in place (providers 02 AC8). + // reinstall replaces our wrapper in place. let original = extractOriginalCommand(from: existingCommand) ?? "" newCommand = wrapCommand(original, helperPath: helperDestURL.path) } else if legacyChain, let legacyConfiguration { @@ -450,28 +401,20 @@ public struct StatuslineHelperInstaller: Sendable { } else if legacySoleHelper { newCommand = helperDestURL.path } else { - // User's own command — chain our helper after it - // (providers 02 AC8). newCommand = wrapCommand(existingCommand, helperPath: helperDestURL.path) } } else { - // No prior statusLine — set our helper as the sole command. newCommand = helperDestURL.path } // Claude Code requires `type: "command"` to invoke the statusLine - // (https://code.claude.com/docs/en/statusline). Always set it on - // install so the helper is actually spawned. + // (https://code.claude.com/docs/en/statusline). statusLineObject.type = "command" statusLineObject.command = newCommand settings.statusLine = .object(statusLineObject) try writeSettings(settings) } - /// Returns the existing `statusLine` as an object if it is one, so sibling - /// keys (`padding`, `refreshInterval`, …) survive the install rewrite. A - /// bare-string `statusLine` yields `nil` here — install normalizes it to - /// the object form (providers 02 AC8). private func existingStatusLineObject(_ value: StatusLineValue?) -> StatusLineObject? { if case let .object(object) = value { return object @@ -487,10 +430,8 @@ public struct StatuslineHelperInstaller: Sendable { } if command.contains(Self.chainStart) { - // Our wrapper is present — extract the original command and - // restore it (providers 02 AC11). Like the original implementation, - // restore as a bare object with only `command`; sibling keys are - // not re-added (byte-equivalence with the prior behaviour, ci 04 AC7). + // Restore as a bare object with only `command`; sibling keys are + // not re-added. let original = extractOriginalCommand(from: command) ?? "" if original.isEmpty { mutable.statusLine = nil @@ -498,25 +439,17 @@ public struct StatuslineHelperInstaller: Sendable { mutable.statusLine = .object(StatusLineObject(command: original)) } } else if command == helperDestURL.path { - // Our helper is the only command — remove the statusLine key - // entirely (providers 02 AC11). mutable.statusLine = nil } - // else: a different command, not ours — leave it alone. try writeSettings(mutable) } - // MARK: - Chain helpers (providers 02 AC8) + // MARK: - Chain helpers - /// Wraps an existing command so it runs first, followed by the helper - /// reading the same stdin. The shell pipeline captures stdin into a - /// variable, pipes it to the original command, then pipes it to our - /// helper (whose output is discarded — it writes the cache directly). - /// - /// Sentinels `chainStart` / `chainSep` bracket the original command - /// so we can detect and extract it on reinstall or uninstall - /// (providers 02 AC8). + /// The shell pipeline captures stdin into a variable, pipes it to the + /// original command, then pipes it to our helper — whose output is + /// discarded since it writes the cache directly. private func wrapCommand(_ original: String, helperPath: String) -> String { let escaped = escapeForShell(original) return "bash -c \"INPUT=$(cat); " @@ -525,9 +458,6 @@ public struct StatuslineHelperInstaller: Sendable { + "echo \\\"$INPUT\\\" | \(helperPath) > /dev/null\"" } - /// Extracts the original user command from between the chain sentinels - /// and unescapes shell-escaped characters so the round-trip is lossless - /// (providers 02 AC8, AC11). private func extractOriginalCommand(from wrapped: String) -> String? { extractOriginalCommand( from: wrapped, @@ -553,8 +483,6 @@ public struct StatuslineHelperInstaller: Sendable { return original.isEmpty ? nil : original } - /// Escapes characters that would break the double-quoted shell string - /// the original command is embedded in. private func escapeForShell(_ input: String) -> String { input.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") @@ -562,8 +490,6 @@ public struct StatuslineHelperInstaller: Sendable { .replacingOccurrences(of: "`", with: "\\`") } - /// Reverses `escapeForShell` so the original command is restored - /// losslessly on uninstall (providers 02 AC11). private func unescapeFromShell(_ input: String) -> String { var result = "" var index = input.startIndex diff --git a/Sources/Providers/Cursor/CursorAuth.swift b/Sources/Providers/Cursor/CursorAuth.swift index 4f1057e..1b1686c 100644 --- a/Sources/Providers/Cursor/CursorAuth.swift +++ b/Sources/Providers/Cursor/CursorAuth.swift @@ -1,6 +1,5 @@ import Foundation -/// The Keychain locations that hold a Cursor CLI OAuth token pair. struct CursorKeychainCredentials: Sendable, Equatable { let accessTokenService: String let accessTokenAccount: String @@ -8,23 +7,18 @@ struct CursorKeychainCredentials: Sendable, Equatable { let refreshTokenAccount: String } -/// Cursor first-party OAuth constants (providers 07 AC5/AC11). -/// /// The `client_id` is Cursor's own CLI/desktop client id — hardcoded in the /// Cursor binary and extracted by reverse-engineering. Cursor offers no /// developer program to register one, so filbert impersonates Cursor's -/// official client on the refresh path. This is the **only** place the id -/// appears; a Cursor rotation is a one-line change + release (providers 07 -/// AC11). +/// official client on the refresh path. This is the only place the id +/// appears; a Cursor rotation is a one-line change + release. enum CursorAuth { - /// Cursor's first-party CLI/desktop client id. static let clientId = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" - /// OAuth token endpoint. Always targets `api2.cursor.sh` directly — the - /// refresh path never goes through a user proxy override (providers 07 AC5). + /// Always targets `api2.cursor.sh` directly — the refresh path never goes + /// through a user proxy override. static let tokenEndpoint = URL(string: "https://api2.cursor.sh/oauth/token")! - /// Credentials written by the current Cursor Agent CLI (`agent login`). static let currentCLIKeychainCredentials = CursorKeychainCredentials( accessTokenService: "cursor-access-token", accessTokenAccount: "cursor-user", @@ -32,7 +26,6 @@ enum CursorAuth { refreshTokenAccount: "cursor-user" ) - /// Credentials for the `cursor-agent` Keychain layout. static let legacyCLIKeychainCredentials = CursorKeychainCredentials( accessTokenService: "cursor-agent", accessTokenAccount: "cursor-access-token", @@ -40,15 +33,12 @@ enum CursorAuth { refreshTokenAccount: "cursor-refresh-token" ) - /// Lookup covers all supported Cursor Agent Keychain layouts. static let keychainCredentials = [ currentCLIKeychainCredentials, legacyCLIKeychainCredentials, ] - /// SQLite key storing the access token in Cursor Desktop's `state.vscdb`. static let sqliteAccessKey = "cursorAuth/accessToken" - /// SQLite key storing the refresh token in Cursor Desktop's `state.vscdb`. static let sqliteRefreshKey = "cursorAuth/refreshToken" } diff --git a/Sources/Providers/Cursor/CursorCredentialVault.swift b/Sources/Providers/Cursor/CursorCredentialVault.swift index 53660cb..dc2f5ab 100644 --- a/Sources/Providers/Cursor/CursorCredentialVault.swift +++ b/Sources/Providers/Cursor/CursorCredentialVault.swift @@ -95,7 +95,6 @@ struct KeychainCursorCredentialVault: CursorCredentialVault { } } - // AC1: drop the Cursor entry from the shared vault (bugs 01). func clear() throws { do { try keychain.delete(for: Self.providerId) diff --git a/Sources/Providers/Cursor/CursorError.swift b/Sources/Providers/Cursor/CursorError.swift index 9e7fc2a..eb82521 100644 --- a/Sources/Providers/Cursor/CursorError.swift +++ b/Sources/Providers/Cursor/CursorError.swift @@ -1,23 +1,14 @@ import Foundation -/// Typed errors for the Cursor provider (providers 07 AC10). -/// -/// Mirrors `ZAIError`'s shape (providers 01) plus Cursor-specific cases: -/// `.missingToken`, `.sessionExpired`, `.clientIdRejected`. public enum CursorError: Error, Equatable, Sendable { - /// No Cursor token pair was found in either local store (providers 07 AC4). case missingToken /// The session is no longer valid — the refresh returned `shouldLogout` - /// or an empty access token (providers 07 AC5). + /// or an empty access token. case sessionExpired - /// Cursor rotated its first-party `client_id`; filbert needs an update - /// (providers 07 AC11). + // Cursor rotated its first-party `client_id`; filbert needs an update. case clientIdRejected - /// Non-200 HTTP status from the usage endpoint. case http(Int) - /// A transport-level failure (connection refused, timeout, DNS, …). case network(Error) - /// The response body could not be decoded. case decoding(Error) public static func == (lhs: CursorError, rhs: CursorError) -> Bool { diff --git a/Sources/Providers/Cursor/CursorLocator.swift b/Sources/Providers/Cursor/CursorLocator.swift index b142f53..97bb096 100644 --- a/Sources/Providers/Cursor/CursorLocator.swift +++ b/Sources/Providers/Cursor/CursorLocator.swift @@ -1,17 +1,9 @@ import Foundation -/// Resolves the user's Cursor CLI binary without invoking a shell -/// (providers 07 AC4b). -/// -/// Mirrors `CodexLocator` (providers 05 AC1): lookup order is the process -/// `PATH`, then known macOS install locations (Homebrew, `/usr/local/bin`, -/// the Cursor desktop app's bundled CLI). A missing CLI is a normal setup -/// state, not an error. public struct CursorLocator: Sendable { private let environment: [String: String] private let isExecutable: @Sendable (String) -> Bool - /// Uses the process environment and filesystem for production lookup. public init() { self.init( environment: ProcessInfo.processInfo.environment, @@ -27,10 +19,6 @@ public struct CursorLocator: Sendable { self.isExecutable = isExecutable } - /// Returns the absolute path to the first executable `cursor-agent` / - /// `cursor` / `agent` binary found through `PATH`, then known macOS - /// install locations, then the Cursor desktop app's bundled CLI. - /// Returns `nil` when no executable is found — never throws. public func resolve() -> String? { let names = ["cursor-agent", "cursor", "agent"] for directory in pathDirectories + knownDirectories { diff --git a/Sources/Providers/Cursor/CursorProvider+CredentialImport.swift b/Sources/Providers/Cursor/CursorProvider+CredentialImport.swift index f901dfd..8364cc2 100644 --- a/Sources/Providers/Cursor/CursorProvider+CredentialImport.swift +++ b/Sources/Providers/Cursor/CursorProvider+CredentialImport.swift @@ -5,8 +5,6 @@ public extension CursorProvider { String(localized: "Re-import Cursor credentials") } - // AC1: clearing credentials is Cursor's removal action — it has no helper - // (bugs 01). The protocol default throws; this clears the shared vault. func removeHelper() async throws { try tokenStore.clearSharedCredentials() } diff --git a/Sources/Providers/Cursor/CursorProvider.swift b/Sources/Providers/Cursor/CursorProvider.swift index dc32b45..7c4b568 100644 --- a/Sources/Providers/Cursor/CursorProvider.swift +++ b/Sources/Providers/Cursor/CursorProvider.swift @@ -1,12 +1,6 @@ import Core import Foundation -/// Provider-owned metadata and resources for Cursor (providers 07). -/// -/// Reads Cursor subscription + on-demand spend from the user's locally stored -/// Cursor auth token and displays the current billing-cycle usage as -/// `ProviderQuota` lines. The provider is `.apiKeyFree`: the user never pastes -/// a key — filbert reads the session Cursor's own apps created locally. public struct CursorProvider: AIProvider { public static let providerId = "cursor" public static let providerName = "Cursor" @@ -48,7 +42,7 @@ public struct CursorProvider: AIProvider { self.rateLimitBackoff = rateLimitBackoff } - // MARK: - Configuration (providers 07 AC10) + // MARK: - Configuration public func isConfigured() -> Bool { (try? tokenStore.loadOrBootstrap()) != nil @@ -63,8 +57,6 @@ public struct CursorProvider: AIProvider { return .error(error.localizedDescription) } - // No token — distinguish binary-present from binary-missing so the - // Settings link targets the right action (providers 07 AC10). if locator.resolve() != nil { return .setup(String(localized: "Sign in to Cursor")) } @@ -77,7 +69,7 @@ public struct CursorProvider: AIProvider { try tokenStore.reimport() } - // MARK: - Fetch (providers 07 AC5/AC6) + // MARK: - Fetch public func fetchQuota(auth _: ProviderAuth, baseURL: URL) async throws -> ProviderQuota { try await rateLimitBackoff.checkRequestAllowed() @@ -96,7 +88,6 @@ public struct CursorProvider: AIProvider { throw error } - // ── AC6: Connect-RPC call (providers 07) ── let endpoint = baseURL .appendingPathComponent("aiserver.v1.DashboardService") .appendingPathComponent("GetCurrentPeriodUsage") @@ -125,7 +116,6 @@ public struct CursorProvider: AIProvider { } await rateLimitBackoff.recordSuccessfulResponse() - // ── AC10: typed errors for non-200 (providers 07) ── guard httpResponse.statusCode == 200 else { throw CursorError.http(httpResponse.statusCode) } @@ -140,19 +130,17 @@ public struct CursorProvider: AIProvider { return map(usageResponse) } - // MARK: - Mapping (providers 07 AC7–AC9) + // MARK: - Mapping func map(_ response: CursorUsageResponse) -> ProviderQuota { let resetDate = Self.dateFromMsString(response.billingCycleEnd) let plan = normalizedPlan(from: response) var lines: [UsageLine] = [] - // ── AC7: plan usage → percentage + reset UsageLines (providers 07) ── if let plan { lines.append(contentsOf: planLines(plan, resetDate: resetDate)) } - // ── AC8: on-demand and pooled spend → currency UsageLines (providers 07) ── if let onDemand = normalizedOnDemand(from: response) { if let line = onDemandLine(onDemand) { lines.append(line) @@ -179,7 +167,7 @@ public struct CursorProvider: AIProvider { ) } - // MARK: Plan usage lines (providers 07 AC7) + // MARK: Plan usage lines private func planLines(_ plan: PlanData, resetDate: Date?) -> [UsageLine] { var lines: [UsageLine] = [] @@ -208,7 +196,6 @@ public struct CursorProvider: AIProvider { details: details.isEmpty ? nil : details )) - // Bonus credits — shown as the bonus amount when present and positive. if let bonus = plan.bonusSpend, bonus > 0 { lines.append(UsageLine( label: String(localized: "Bonus credits"), @@ -222,10 +209,9 @@ public struct CursorProvider: AIProvider { return lines } - // MARK: On-demand line (providers 07 AC8) + // MARK: On-demand line private func onDemandLine(_ onDemand: OnDemandData) -> UsageLine? { - // Absent or zero-limit on-demand produces no line (providers 07 AC8). guard let limit = onDemand.limit, limit > 0 else { return nil } return UsageLine( label: String(localized: "On-demand spend"), @@ -235,7 +221,7 @@ public struct CursorProvider: AIProvider { ) } - // MARK: Pooled line (providers 07 AC8) + // MARK: Pooled line private func pooledLine(_ spend: CursorSpendLimitUsage) -> UsageLine? { guard let limit = spend.pooledLimit, limit > 0 else { return nil } @@ -249,7 +235,7 @@ public struct CursorProvider: AIProvider { ) } - // MARK: Headline (providers 07 AC9) + // MARK: Headline private func computeHeadline( response: CursorUsageResponse, @@ -279,7 +265,7 @@ public struct CursorProvider: AIProvider { // MARK: - Normalization /// Unifies the new `planUsage` shape and the legacy `individualUsage.plan` - /// shape into one model (providers 07 AC7). + /// shape into one model. private func normalizedPlan(from response: CursorUsageResponse) -> PlanData? { if let plan = response.planUsage { return PlanData( @@ -328,7 +314,6 @@ private extension CursorProvider { return formatter } - /// Converts a unix-milliseconds string to `Date` (providers 07 Context). static func dateFromMsString(_ milliseconds: String?) -> Date? { guard let milliseconds, let epoch = TimeInterval(milliseconds) else { return nil } return Date(timeIntervalSince1970: epoch / 1000) @@ -351,17 +336,15 @@ private struct OnDemandData { let limit: Int? } -// MARK: - Wire types (providers 07 AC7/AC8) +// MARK: - Wire types -/// Envelope for `GetCurrentPeriodUsage` (providers 07 AC7). Tolerant of -/// unknown fields and missing optionals (providers 07 AC7). struct CursorUsageResponse: Decodable, Sendable { let billingCycleStart: String? let billingCycleEnd: String? let isUnlimited: Bool? let planUsage: CursorPlanUsage? let spendLimitUsage: CursorSpendLimitUsage? - /// Legacy shape tolerated for older API responses (providers 07 AC7). + /// Legacy shape tolerated for older API responses. let individualUsage: CursorIndividualUsage? } diff --git a/Sources/Providers/Cursor/CursorTokenStore.swift b/Sources/Providers/Cursor/CursorTokenStore.swift index 8fa4a5b..768f824 100644 --- a/Sources/Providers/Cursor/CursorTokenStore.swift +++ b/Sources/Providers/Cursor/CursorTokenStore.swift @@ -17,13 +17,6 @@ enum CursorExternalCredentialError: Error, Equatable, LocalizedError { } } -/// Loads Cursor credentials from Filbert's shared vault. When the vault has -/// no Cursor record, one bootstrap attempt imports the first complete pair -/// from Cursor Agent Keychain layouts, then Cursor Desktop SQLite. -/// -/// External Keychain reads route through Core's `KeychainStorage` accessor -/// with the session-scoped `KeychainAuthenticationContext` so Cursor stops -/// owning `LAContext` or SecItem query code (core 07 AC4). struct CursorTokenStore: Sendable { private let vault: any CursorCredentialVault private let externalStorage: any KeychainStorage @@ -69,7 +62,6 @@ struct CursorTokenStore: Sendable { importCoordinator = CursorImportCoordinator() } - /// Loads the shared Cursor pair and performs at most one initial import. func loadOrBootstrap() throws -> CursorTokenPair? { try importCoordinator.loadOrBootstrap( loadShared: { try vault.load() }, @@ -78,8 +70,7 @@ struct CursorTokenStore: Sendable { ) } - /// Deliberately re-reads Cursor-owned stores and persists the result into - /// Filbert's vault. No normal configuration or refresh path calls this. + /// No normal configuration or refresh path calls this. func reimport() throws { _ = try importCoordinator.reimport( importExternal: { try loadExternalPair() }, @@ -87,8 +78,7 @@ struct CursorTokenStore: Sendable { ) } - /// Drops the Cursor pair from Filbert's shared vault (bugs 01). Leaves - /// Cursor's own first-party stores untouched. + /// Leaves Cursor's own first-party stores untouched. func clearSharedCredentials() throws { try vault.clear() } @@ -119,9 +109,7 @@ struct CursorTokenStore: Sendable { } /// Reads a UTF-8 token from an external Keychain item via Core's shared - /// accessor. Absence (`errSecItemNotFound`) returns `nil`; any other - /// status surfaces as `CursorExternalCredentialError.keychain`, and a - /// non-UTF-8 payload surfaces as `malformedKeychainRecord` (core 05 AC4). + /// accessor. private func readExternalToken( service: String, account: String @@ -161,10 +149,8 @@ struct CursorTokenStore: Sendable { "\(homeDirectory)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" } - // MARK: - Refresh (providers 07 AC5/AC11) + // MARK: - Refresh - /// Returns a valid access token, refreshing the JWT when the current one - /// is expired or within the skew window. func ensureValidAccessToken(_ pair: CursorTokenPair) async throws -> String { if let expiry = Self.jwtExpiry(pair.accessToken) { guard expiry > Date().addingTimeInterval(refreshSkew) else { @@ -229,8 +215,6 @@ struct CursorTokenStore: Sendable { // MARK: - JWT expiry (no third-party library) /// Minimal subset of a JWT payload: only the `exp` (expiry) claim is read. - /// Replaces `JSONSerialization` + `[String: Any]` so `Sources/` stays free - /// of the `Any` type (ci 04 AC7). private struct JWTPayload: Decodable { let exp: Double } diff --git a/Sources/Providers/DeepSeek/DeepSeekProvider.swift b/Sources/Providers/DeepSeek/DeepSeekProvider.swift index b73495e..bd40d19 100644 --- a/Sources/Providers/DeepSeek/DeepSeekProvider.swift +++ b/Sources/Providers/DeepSeek/DeepSeekProvider.swift @@ -3,8 +3,6 @@ import Foundation // MARK: - Diagnostic logging -/// Lightweight stderr logger so `swift run` surfaces what DeepSeek actually -/// returned. Diagnostic only. enum DeepSeekLog { static func log(_ message: @autoclosure () -> String) { FileHandle.standardError.write(Data("[DeepSeekProvider] \(message())\n".utf8)) @@ -18,9 +16,6 @@ public enum DeepSeekError: Error, Equatable, Sendable { case http(Int) case network(Error) case decoding(Error) - /// The registry routed `.apiKeyFree` auth to this provider, which is a - /// contract-integrity violation — DeepSeek always expects an API key - /// (core 03 AC3). case internalInconsistency public static func == (lhs: DeepSeekError, rhs: DeepSeekError) -> Bool { @@ -56,7 +51,6 @@ extension DeepSeekError: LocalizedError { // MARK: - Wire types (private to this module) -/// Top-level envelope for `GET /user/balance` (providers 04 AC2/AC3). private struct DeepSeekBalanceResponse: Decodable { let isAvailable: Bool let balanceInfos: [DeepSeekBalanceInfo] @@ -67,9 +61,8 @@ private struct DeepSeekBalanceResponse: Decodable { } } -/// One currency entry in the balance response. Wire shape is strings; the -/// model wants numbers, so conversion happens in the mapping step (providers -/// 04 AC2). +/// Wire shape is strings; the model wants numbers, so conversion happens in +/// the mapping step. private struct DeepSeekBalanceInfo: Decodable { let currency: String let totalBalance: String @@ -91,8 +84,7 @@ public struct DeepSeekProvider: AIProvider { public static let providerName = "DeepSeek" public static let providerGlyph = ProviderGlyph.asset(name: "ProviderGlyph", bundle: .module) public static let providerDescription = String(localized: "Monitor prepaid balance") - /// Host root for DeepSeek requests; path segments live in `fetchQuota` - /// (core 02 AC1/AC8). + /// Host root for DeepSeek requests; path segments live in `fetchQuota`. public static let baseURL = URL(string: "https://api.deepseek.com")! private let session: URLSession @@ -107,8 +99,6 @@ public struct DeepSeekProvider: AIProvider { case let .apiKey(key): apiKey = key case .apiKeyFree: - // The registry never routes .apiKeyFree to DeepSeek — this is a - // contract-integrity assertion (core 03 AC3). throw DeepSeekError.internalInconsistency } @@ -116,7 +106,6 @@ public struct DeepSeekProvider: AIProvider { throw DeepSeekError.missingKey } - // AC1: path is fixed; only the host comes from `baseURL` (core 02 AC8). let endpoint = baseURL .appendingPathComponent("user") .appendingPathComponent("balance") @@ -159,14 +148,12 @@ public struct DeepSeekProvider: AIProvider { // MARK: - Mapping - /// Converts the decoded envelope into a `ProviderQuota`. Always emits the - /// balance lines so the user can see what's left even when the account is - /// marked unavailable (providers 04 AC3). + /// Always emits the balance lines so the user can see what's left even + /// when the account is marked unavailable. private func map(_ response: DeepSeekBalanceResponse) -> ProviderQuota { let lines = response.balanceInfos.flatMap { info -> [UsageLine] in - // AC2: total → `total`, granted/topped-up → their own lines, all - // tagged with the raw currency code. Currency formatting (symbol, - // decimals) is the UI's job, so `used` is not derived here. + // Currency formatting (symbol, decimals) is the UI's job, so + // `used` is not derived here. let currency = info.currency return [ UsageLine( @@ -201,9 +188,6 @@ public struct DeepSeekProvider: AIProvider { ) } - /// AC3 + AC4: when `is_available == false`, surface it explicitly; when - /// true, format the first balance as `" left"`; fall back - /// to the localized "No data" string when no balance parsed. private func computeHeadline( response: DeepSeekBalanceResponse, lines: [UsageLine] @@ -224,9 +208,6 @@ public struct DeepSeekProvider: AIProvider { return String(localized: "\(amount) left") } - /// Locale-aware currency formatter for the headline (providers 04 AC4). - /// Local to this provider: per-AC2 currency formatting of the underlying - /// values is still the UI's job; this only styles the headline amount. private static func currencyFormatter(currencyCode: String) -> NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .currency diff --git a/Sources/Providers/OpenAICodex/CodexAppServerClient.swift b/Sources/Providers/OpenAICodex/CodexAppServerClient.swift index b6bce00..fa76808 100644 --- a/Sources/Providers/OpenAICodex/CodexAppServerClient.swift +++ b/Sources/Providers/OpenAICodex/CodexAppServerClient.swift @@ -49,7 +49,6 @@ struct CodexCredits: Decodable, Sendable { let unlimited: Bool? } -/// Executes one bounded, read-only Codex app-server request (providers 05 AC3). struct CodexAppServerClient: Sendable { private let timeout: TimeInterval diff --git a/Sources/Providers/OpenAICodex/CodexLocator.swift b/Sources/Providers/OpenAICodex/CodexLocator.swift index c3e9218..70067b4 100644 --- a/Sources/Providers/OpenAICodex/CodexLocator.swift +++ b/Sources/Providers/OpenAICodex/CodexLocator.swift @@ -1,12 +1,9 @@ import Foundation -/// Resolves the user's `codex` executable without invoking a shell -/// (providers 05 AC1). public struct CodexLocator: Sendable { private let environment: [String: String] private let isExecutable: @Sendable (String) -> Bool - /// Uses the process environment and filesystem for production lookup. public init() { self.init( environment: ProcessInfo.processInfo.environment, @@ -22,8 +19,6 @@ public struct CodexLocator: Sendable { self.isExecutable = isExecutable } - /// Returns the first executable from `PATH`, then common macOS install - /// locations. A missing CLI is a normal setup state, not an error. public func resolve() -> String? { for directory in pathDirectories + knownDirectories { let candidate = (directory as NSString).appendingPathComponent("codex") diff --git a/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift b/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift index 6e12f1e..fbce54e 100644 --- a/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift +++ b/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift @@ -17,8 +17,6 @@ extension OpenAICodexError: LocalizedError { } } -/// Maps Codex app-server rate-limit snapshots into Core's generic quota model -/// without accessing Codex credentials (providers 05 AC4). public struct OpenAICodexProvider: AIProvider { public static let providerId = "openai-codex" public static let providerName = "OpenAI Codex" diff --git a/Sources/Providers/ZAI/ZAIProvider.swift b/Sources/Providers/ZAI/ZAIProvider.swift index 97b33bd..4387074 100644 --- a/Sources/Providers/ZAI/ZAIProvider.swift +++ b/Sources/Providers/ZAI/ZAIProvider.swift @@ -3,9 +3,6 @@ import Foundation // MARK: - Diagnostic logging -/// Lightweight stderr logger so `swift run` surfaces what z.ai actually -/// returned. Diagnostic only — remove or gate behind a flag once the wire -/// format is confirmed stable. enum ZAILog { static func log(_ message: @autoclosure () -> String) { FileHandle.standardError.write(Data("[ZAIProvider] \(message())\n".utf8)) @@ -19,8 +16,6 @@ public enum ZAIError: Error, Equatable, Sendable { case http(Int) case network(Error) case decoding(Error) - /// The registry routed `.apiKeyFree` auth to this provider, which is a - /// contract-integrity violation — z.ai always expects an API key (core 03 AC3). case internalInconsistency public static func == (lhs: ZAIError, rhs: ZAIError) -> Bool { @@ -87,8 +82,7 @@ private struct ZAILimitLabel { let unit: Int let label: String - /// Recognized (type, unit) pairs (providers 01 AC2). - /// Unknown pairs are silently ignored during mapping. + /// Unknown (type, unit) pairs are silently ignored during mapping. static let known: [ZAILimitLabel] = [ ZAILimitLabel(type: "TOKENS_LIMIT", unit: 3, label: "5-hour window"), ZAILimitLabel(type: "TOKENS_LIMIT", unit: 6, label: "Weekly"), @@ -100,35 +94,26 @@ private struct ZAILimitLabel { } } -// MARK: - Peak-hours metadata (ui 04 AC3/AC4) +// MARK: - Peak-hours metadata /// GLM Coding Plan peak-hours rules sourced from zai-bar's README. /// Last verified: 2026-07-21. -/// -/// These are provider-level constants — the view layer reads them so -/// pricing rules aren't buried in UI-only code. When z.ai announces -/// a change (extended promo, new multiplier, different peak window), -/// updating this single location is sufficient. public enum ZAIPeakHours { /// China Standard Time (UTC+8, no DST). public static let timeZone = TimeZone(identifier: "Asia/Shanghai") - /// Peak window: 14:00–18:00 in Asia/Shanghai. public static let peakStartHour = 14 public static let peakEndHour = 18 /// Advanced-model (GLM-5.2 / GLM-5-Turbo) multiplier during peak hours. public static let peakMultiplier = 3 - /// Off-peak multiplier after the limited-time promo ends. public static let offPeakMultiplier = 2 - /// Off-peak multiplier while the limited-time promo is active. public static let promoMultiplier = 1 - /// Limited-time promo cutoff: 2026-10-01 00:00 Asia/Shanghai. - /// After this date the off-peak multiplier flips from - /// `promoMultiplier` to `offPeakMultiplier`. + /// After this date the off-peak multiplier flips from `promoMultiplier` + /// to `offPeakMultiplier`. public static let promoEndDate: Date = { var components = DateComponents() components.year = 2026 @@ -148,11 +133,9 @@ public struct ZAIProvider: AIProvider { public static let providerName = "z.ai" public static let providerGlyph = ProviderGlyph.asset(name: "ProviderGlyph", bundle: .module) public static let providerDescription = String(localized: "Monitor API usage and quotas") - /// Host root for z.ai requests; path segments live in `fetchQuota` (core 02 AC1/AC8). + /// Host root for z.ai requests; path segments live in `fetchQuota`. public static let baseURL = URL(string: "https://api.z.ai")! - /// Provider-agnostic config the view layer reads for the peak-hours block. - /// Sourced from `ZAIPeakHours` (zai-bar README). public static let peakHoursConfig = PeakHoursConfig( timeZone: ZAIPeakHours.timeZone, peakStartHour: ZAIPeakHours.peakStartHour, @@ -175,8 +158,6 @@ public struct ZAIProvider: AIProvider { case let .apiKey(key): apiKey = key case .apiKeyFree: - // The registry never routes .apiKeyFree to ZAI — this is a - // contract-integrity assertion (core 03 AC3). throw ZAIError.internalInconsistency } @@ -184,7 +165,6 @@ public struct ZAIProvider: AIProvider { throw ZAIError.missingKey } - // Path is plan-agnostic; only the host comes from `baseURL` (core 02 AC8). let endpoint = baseURL .appendingPathComponent("api") .appendingPathComponent("monitor") @@ -193,9 +173,10 @@ public struct ZAIProvider: AIProvider { .appendingPathComponent("limit") var request = URLRequest(url: endpoint) request.httpMethod = "GET" - // z.ai's monitor endpoint expects the raw token, NOT an "Authorization: Bearer …" - // scheme. Sending a "Bearer " prefix is rejected as unauthenticated. This holds - // for both regular API and Coding Plan keys — the endpoint is plan-agnostic. + // z.ai's monitor endpoint expects the raw token, NOT an + // "Authorization: Bearer …" scheme. Sending a "Bearer " prefix is + // rejected as unauthenticated. This holds for both regular API and + // Coding Plan keys — the endpoint is plan-agnostic. request.setValue(apiKey, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -245,7 +226,6 @@ public struct ZAIProvider: AIProvider { guard let line = mapLimit(limit) else { continue } lines.append(line) - // Track for headline priority (AC5) if limit.type == "TOKENS_LIMIT", limit.unit == 3 { fiveHourLimit = limit } else if limit.type == "TOKENS_LIMIT", limit.unit == 6 { @@ -268,8 +248,6 @@ public struct ZAIProvider: AIProvider { ) } - /// Maps a single z.ai limit to a UsageLine, or nil when the (type, unit) - /// pair is unrecognized (providers 01 AC2). private func mapLimit(_ limit: ZAILimit) -> UsageLine? { guard let labelKey = ZAILimitLabel.lookup(type: limit.type, unit: limit.unit) else { return nil @@ -316,7 +294,6 @@ public struct ZAIProvider: AIProvider { ) } - /// Builds the headline string using 5-hour → weekly priority (providers 01 AC5). private func computeHeadline( fiveHourLimit: ZAILimit?, weeklyLimit: ZAILimit? diff --git a/Tests/AppTests/ConfiguredProvidersOrderedTests.swift b/Tests/AppTests/ConfiguredProvidersOrderedTests.swift index c876eff..3cc4a93 100644 --- a/Tests/AppTests/ConfiguredProvidersOrderedTests.swift +++ b/Tests/AppTests/ConfiguredProvidersOrderedTests.swift @@ -4,11 +4,6 @@ import XCTest @MainActor final class ConfiguredProvidersOrderedTests: XCTestCase { - /// (ui 16) `configuredProvidersOrdered` must list only configured - /// providers, in the saved order, and stay in sync with - /// `configuredProviderIds`. The unconfigured `.apiKey` provider (no - /// Keychain entry) and the unconfigured `.apiKeyFree` provider must not - /// appear, while the configured `.apiKeyFree` provider must. func testConfiguredProvidersOrderedExcludesUnconfiguredProviders() { let registry = ProviderRegistry() registry.register(UnconfiguredAPIKeyProvider()) @@ -24,13 +19,10 @@ final class ConfiguredProvidersOrderedTests: XCTestCase { XCTAssertEqual( configuredOrderedIds, configuredIds, - "configuredProvidersOrdered must match configuredProviderIds (ui 16)" + "configuredProvidersOrdered must match configuredProviderIds" ) } - /// (ui 16) The full registry order (`registeredProvidersOrdered`) - /// still includes unconfigured providers, so their saved positions are - /// preserved when only configured providers are visible. func testRegisteredOrderStillContainsUnconfiguredProviders() { let registry = ProviderRegistry() registry.register(UnconfiguredAPIKeyProvider()) @@ -43,8 +35,6 @@ final class ConfiguredProvidersOrderedTests: XCTestCase { XCTAssertTrue(registeredIds.contains(ConfiguredAPIKeyFreeProvider.providerId)) } - /// (ui 16) With no configured providers, the configured list is empty so - /// the Appearance tab renders the empty hint instead of a list. func testConfiguredProvidersOrderedEmptyWhenNothingConfigured() { let registry = ProviderRegistry() registry.register(UnconfiguredAPIKeyProvider()) diff --git a/Tests/AppTests/MenuBarStatusIconTests.swift b/Tests/AppTests/MenuBarStatusIconTests.swift index 9ae6686..9984fce 100644 --- a/Tests/AppTests/MenuBarStatusIconTests.swift +++ b/Tests/AppTests/MenuBarStatusIconTests.swift @@ -2,11 +2,8 @@ import Core import XCTest -// MARK: - MenuBarStatusIcon resolution logic (ui 10) +// MARK: - MenuBarStatusIcon resolution logic -/// Exercises the pure `QuotaStatusResolver` that drives the menu-bar icon's -/// branch selection (ui 10 Plan §6). The ring geometry itself is verified via -/// `QuotaStatusResolver.clampedFraction` — the same function the view calls. final class MenuBarStatusIconTests: XCTestCase { private let suiteName = "filbert.tests.menu-bar-status-icon" private var defaults: UserDefaults! @@ -26,7 +23,7 @@ final class MenuBarStatusIconTests: XCTestCase { super.tearDown() } - // MARK: - AC3: window-based provider → percentage mode + // MARK: - window-based provider → percentage mode func testResolve_windowPercentage_returnsWindowMode() { let quota = ProviderQuota( @@ -44,10 +41,6 @@ final class MenuBarStatusIconTests: XCTestCase { } func testResolve_5HourBeforeWeekly_picks5HourFirst() { - // Mirrors the Claude Code provider's line ordering (providers 02 AC5): - // 5-hour window first, then weekly. The icon picks the first one with a - // non-nil percentage so it agrees with the popover's headline priority - // (ui 04 AC2, providers 01 AC5). let quota = ProviderQuota( providerId: "claude-code", providerName: "Claude Code", @@ -78,12 +71,9 @@ final class MenuBarStatusIconTests: XCTestCase { XCTAssertEqual(status, .window(percentage: 25)) } - // MARK: - AC4: balance-based provider → balance mode + // MARK: - balance-based provider → balance mode func testResolve_noPercentageButPositiveTotal_returnsBalanceMode() { - // DeepSeek emits balance lines with `used: nil` (providers 04) so the - // percentage derivation returns nil and the line is treated as a - // balance-only line (ui 08 AC3). let quota = ProviderQuota( providerId: "deepseek", providerName: "DeepSeek", @@ -104,8 +94,6 @@ final class MenuBarStatusIconTests: XCTestCase { } func testResolve_multipleBalanceLines_picksFirstPositiveTotal() { - // Mirrors `headlineBalanceColor(for:)`'s selection rule (ui 08 AC3): - // the first balance-only line with a positive total drives the ring. let quota = ProviderQuota( providerId: "deepseek", providerName: "DeepSeek", @@ -124,7 +112,7 @@ final class MenuBarStatusIconTests: XCTestCase { XCTAssertEqual(total, 12.34, accuracy: 0.001) } - // MARK: - AC5: fallback when no usable data + // MARK: - fallback when no usable data func testResolve_noLines_returnsFallback() { let quota = ProviderQuota( @@ -153,9 +141,6 @@ final class MenuBarStatusIconTests: XCTestCase { } func testResolve_percentageWinsOverBalance_whenBothPresent() { - // Capped API plans can return both (per core 01). The icon picks the - // percentage line (ui 10 AC3) so it agrees with the popover's headline. - // The balance line keeps `used: nil` to stay a real balance line. let quota = ProviderQuota( providerId: "capped", providerName: "Capped", @@ -170,12 +155,9 @@ final class MenuBarStatusIconTests: XCTestCase { XCTAssertEqual(QuotaStatusResolver.resolve(for: quota), .window(percentage: 30)) } - // MARK: - AC4: balance ring fraction + // MARK: - balance ring fraction func testResolve_balanceWithNilUsed_drivesFullRingAtRender() { - // Real balance providers emit `used: nil` (providers 04). AC4: a full - // ring is drawn when `used` is nil but `total > 0`. The resolver hands - // the used value through; the ring view decides to draw a full circle. let quota = ProviderQuota( providerId: "deepseek", providerName: "DeepSeek", @@ -193,7 +175,7 @@ final class MenuBarStatusIconTests: XCTestCase { XCTAssertEqual(total, 12.34, accuracy: 0.001) } - // MARK: - AC6: clamping + // MARK: - clamping func testClampedFraction_clampsNegativeToZero() { XCTAssertEqual(QuotaStatusResolver.clampedFraction(-5), 0) @@ -209,9 +191,6 @@ final class MenuBarStatusIconTests: XCTestCase { } func testResolve_outOfRangePercentage_isPassedThroughAndClampedAtRender() { - // The resolver does not clamp the percentage itself — the ring view - // clamps at draw time (ui 10 AC6). Verify the resolver hands the raw - // value through and the clamp helper handles the edge. let quota = ProviderQuota( providerId: "buggy", providerName: "Buggy", diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeLocatorTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeLocatorTests.swift index 62fc6fa..1549124 100644 --- a/Tests/ClaudeCodeProviderTests/ClaudeCodeLocatorTests.swift +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeLocatorTests.swift @@ -2,21 +2,21 @@ import XCTest final class ClaudeCodeLocatorTests: XCTestCase { - // MARK: - AC1: resolve() returns injected path + // MARK: - resolve() returns injected path func testResolve_returnsInjectedPath() { let locator = ClaudeCodeLocator(injectedPath: "/usr/local/bin/claude") XCTAssertEqual(locator.resolve(), "/usr/local/bin/claude") } - // MARK: - AC1: resolve() returns nil when binary not found (injected) + // MARK: - resolve() returns nil when binary not found (injected) func testResolve_returnsNil_whenInjectedAsNotFound() { let locator = ClaudeCodeLocator(injectedPath: nil) XCTAssertNil(locator.resolve()) } - // MARK: - AC1: real resolve runs without crashing + // MARK: - real resolve runs without crashing func testResolve_realResolution_doesNotThrow() { // Production initializer — may or may not find claude depending on diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderProactiveRefreshTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderProactiveRefreshTests.swift index 9a17ba8..e1343b3 100644 --- a/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderProactiveRefreshTests.swift +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderProactiveRefreshTests.swift @@ -2,13 +2,6 @@ import Core import XCTest -/// Tests for `ClaudeCodeProvider`'s conformance to `ProactiveRefreshable` -/// (providers 03 AC3) and the isolation between `proactiveRefresh()` and -/// `fetchQuota` (providers 03 AC3 — auto-refresh must not spawn `claude`). -/// -/// Extracted from `ClaudeCodeProviderTests` so the main suite stays under -/// SwiftLint's `type_body_length` limit. Both suites share the same fake- -/// binary fixture pattern. final class ClaudeCodeProviderProactiveRefreshTests: XCTestCase { private var cacheURL: URL! private var tmpDir: URL! @@ -33,25 +26,21 @@ final class ClaudeCodeProviderProactiveRefreshTests: XCTestCase { super.tearDown() } - // MARK: - AC7: registry downcast succeeds + // MARK: - registry downcast succeeds func testClaudeCodeProvider_conformsToProactiveRefreshable() { let provider = makeProvider() - // The downcast must succeed so the registry can route manual refresh - // clicks through `proactiveRefresh(for:)` (providers 03 AC7). // Erase to `any AIProvider` so the compiler cannot statically prove // the conformance — the test is about runtime behaviour. let anyProvider: any AIProvider = provider XCTAssert(anyProvider is ProactiveRefreshable) } - // MARK: - AC3: proactiveRefresh delegates to the refresher + // MARK: - proactiveRefresh delegates to the refresher func testProactiveRefresh_delegatesToRefresher_andRecordsDebounce() async throws { - // We can't stub the refresher actor directly. Instead, point it at a - // fake binary that records each spawn to a log file. The first call - // spawns; a second call within the debounce window should NOT spawn - // again — proving the delegation recorded the debounce timestamp. + // We can't stub the refresher actor directly, so point it at a fake + // binary that records each spawn to a log file. let spawnLogURL = tmpDir.appendingPathComponent("spawn.log") let fakeBinaryURL = try writeFakeClaudeBinary( body: "#!/bin/bash\necho spawned >> \"\(spawnLogURL.path)\"\n" @@ -73,11 +62,9 @@ final class ClaudeCodeProviderProactiveRefreshTests: XCTestCase { XCTAssertEqual(spawnCount, 1, "Second call must be debounced by the refresher") } - // MARK: - AC3: fetchQuota must NOT spawn `claude` + // MARK: - fetchQuota must NOT spawn `claude` func testFetchQuota_doesNotSpawn_whenCalledDirectly() async throws { - // `fetchQuota` is the auto-refresh entry point. It must remain a pure - // cache read and never spawn `claude` (providers 03 AC3). let spawnLogURL = tmpDir.appendingPathComponent("spawn.log") let fakeBinaryURL = try writeFakeClaudeBinary( body: "#!/bin/bash\necho spawned >> \"\(spawnLogURL.path)\"\n" @@ -111,7 +98,6 @@ final class ClaudeCodeProviderProactiveRefreshTests: XCTestCase { ) } - /// Mirrors the helper in the main suite so the tests share one shape. private func makeInstaller(helperInstalled: Bool) -> StatuslineHelperInstaller { let helperURL: URL = if helperInstalled { URL(fileURLWithPath: "/bin/sh") diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderTests.swift index fbd7c2b..9328e82 100644 --- a/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderTests.swift +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeProviderTests.swift @@ -59,7 +59,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertEqual(info.setupHelp, try XCTUnwrap(ClaudeCodeProvider.setupHelp)) } - // MARK: - AC3: isConfigured does not touch Keychain + // MARK: - isConfigured does not touch Keychain func testIsConfigured_trueWhenBinaryFoundAndHelperInstalled() { let locator = ClaudeCodeLocator(injectedPath: "/usr/local/bin/claude") @@ -82,7 +82,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertFalse(provider.isConfigured()) } - // MARK: - AC3: currentSetupState reports binary and helper status + // MARK: - currentSetupState reports binary and helper status func testCurrentSetupState_setupReasonWhenBinaryMissing() async { let locator = ClaudeCodeLocator(injectedPath: nil) @@ -116,7 +116,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertNil(state) } - // MARK: - AC3/AC4: canInstallHelper gating logic (ui 05) + // MARK: - canInstallHelper gating logic func testCanInstallHelper_trueWhenBinaryFoundAndHelperNotInstalled() { let locator = ClaudeCodeLocator(injectedPath: "/usr/local/bin/claude") @@ -139,7 +139,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertFalse(provider.canInstallHelper()) } - // MARK: - AC2: internal-consistency assertion + // MARK: - internal-consistency assertion func testFetchQuota_throwsInternalInconsistencyForApiKey() async throws { let provider = makeProvider() @@ -154,7 +154,7 @@ final class ClaudeCodeProviderTests: XCTestCase { } } - // MARK: - AC4/AC10: no cache file → error quota + // MARK: - no cache file → error quota func testFetchQuota_returnsErrorQuota_whenNoCache() async throws { let provider = makeProvider() @@ -170,7 +170,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertTrue(quota.error?.contains("Open Claude Code") ?? false) } - // MARK: - AC5: both windows → two UsageLines + // MARK: - both windows → two UsageLines func testFetchQuota_mapsBothWindows() async throws { try writeCache(fiveHourPct: 42, fiveHourReset: 1_713_127_600, @@ -195,7 +195,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertEqual(quota.lines[1].percentage, 60) } - // MARK: - AC5: only five_hour + // MARK: - only five_hour func testFetchQuota_mapsOnlyFiveHour() async throws { try writeCache(fiveHourPct: 15, fiveHourReset: 1_713_127_600, @@ -212,7 +212,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertEqual(quota.lines[0].percentage, 15) } - // MARK: - AC5: only seven_day + // MARK: - only seven_day func testFetchQuota_mapsOnlySevenDay() async throws { try writeCache(fiveHourPct: nil, fiveHourReset: nil, @@ -229,7 +229,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertEqual(quota.lines[0].percentage, 80) } - // MARK: - AC5: no `used`, `total`, or `unit` synthesized + // MARK: - no `used`, `total`, or `unit` synthesized func testFetchQuota_noSynthesizedFields() async throws { try writeCache(fiveHourPct: 42, fiveHourReset: 1_713_127_600, @@ -248,10 +248,9 @@ final class ClaudeCodeProviderTests: XCTestCase { } } - // MARK: - AC5/AC6: no rate_limits → "No data" + // MARK: - no rate_limits → "No data" func testFetchQuota_noDataWhenRateLimitsAbsent() async throws { - // Write cache with no rate_limits key. let json = Data(#"{"written_at": 1713000000}"#.utf8) try json.write(to: cacheURL, options: .atomic) @@ -265,7 +264,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertTrue(quota.lines.isEmpty) } - // MARK: - AC6: headline priority (5-hour → weekly) + // MARK: - headline priority (5-hour → weekly) func testFetchQuota_headlineUsesFiveHourPriority() async throws { try writeCache(fiveHourPct: 42, fiveHourReset: futureEpoch(), @@ -296,10 +295,9 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertTrue(quota.headline.contains("resets")) } - // MARK: - AC5b/AC10: isStale flag + // MARK: - isStale flag func testFetchQuota_isStaleTrue_whenCacheOlderThanFreshnessThreshold() async throws { - // Write a cache with written_at far in the past. let staleEpoch = Date().timeIntervalSince1970 - ClaudeCodeProvider.freshnessThreshold - 60 let store = StatuslineCacheStore(cacheURL: cacheURL) @@ -322,7 +320,6 @@ final class ClaudeCodeProviderTests: XCTestCase { } func testFetchQuota_isStaleFalse_whenCacheIsFresh() async throws { - // Write a cache with written_at = now. let store = StatuslineCacheStore(cacheURL: cacheURL) let cache = StatuslineCache( writtenAt: Date().timeIntervalSince1970, @@ -342,7 +339,7 @@ final class ClaudeCodeProviderTests: XCTestCase { XCTAssertFalse(quota.isStale, "isStale should be false for fresh cache") } - // MARK: - AC5: lastUpdated derived from written_at + // MARK: - lastUpdated derived from written_at func testFetchQuota_lastUpdatedMatchesWrittenAt() async throws { let epoch = 1_713_127_600 as TimeInterval @@ -369,15 +366,12 @@ final class ClaudeCodeProviderTests: XCTestCase { ) } - // MARK: - AC12: ZAI orthogonality + // MARK: - ZAI orthogonality func testZAIProvider_isUnaffected() { - // ZAIProvider compiles unchanged and its identity remains. - // We verify that importing Core and checking ProviderAuth still works. let shape = ProviderAuth.Shape.apiKey XCTAssertEqual(shape, .apiKey) - // isStale defaults to false, so existing ZAI quotas are unaffected. let quota = ProviderQuota( providerId: "zai", providerName: "z.ai", @@ -403,9 +397,6 @@ final class ClaudeCodeProviderTests: XCTestCase { ) } - /// Creates an installer pointed at temp paths. When `helperInstalled` is - /// `false` the helper destination is a nonexistent path so - /// `isHelperInstalled()` returns `false`. private func makeInstaller(helperInstalled: Bool) -> StatuslineHelperInstaller { let helperURL: URL = if helperInstalled { // Use a known executable so isHelperInstalled() returns true. @@ -420,8 +411,6 @@ final class ClaudeCodeProviderTests: XCTestCase { ) } - /// Writes a cache fixture to the temp cache URL. Pass `nil` for a - /// window's percentage to omit that window. private func writeCache( fiveHourPct: Double?, fiveHourReset: TimeInterval?, diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherTests.swift index 662f8df..7556546 100644 --- a/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherTests.swift +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherTests.swift @@ -2,12 +2,6 @@ import Core import XCTest -/// Tests for the spawn mechanics in `ClaudeCodeRefresher` (providers 03). -/// -/// The refresher treats `claude` as an opaque binary: it spawns whatever -/// path the locator returns and waits for exit. These tests exploit that by -/// pointing the locator at tiny shell scripts that record what the refresher -/// did — without ever needing a real `claude` install in CI. final class ClaudeCodeRefresherTests: XCTestCase { private var tmpDir: URL! private var invocationLogURL: URL! @@ -38,8 +32,6 @@ final class ClaudeCodeRefresherTests: XCTestCase { super.tearDown() } - /// A refresher wired to the temp cache file so tests never touch the real - /// `~/.cache/filbert/claude-code.json`. private func makeRefresher( binaryPath: String?, spawnTimeout: TimeInterval = 30, @@ -55,7 +47,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { ) } - // MARK: - AC1: argv is exactly the documented flags + // MARK: - argv is exactly the documented flags func testRefresh_spawnsClaudeWithDocumentedArgv() async throws { let fakeBinary = try writeFakeBinary( @@ -78,7 +70,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { ) } - // MARK: - AC1 (providers 03): stream-json output is parsed into the cache + // MARK: - stream-json output is parsed into the cache func testRefresh_parsesUsageOutputIntoCache() async throws { // The fake binary emits what `claude -p "/usage" --output-format json` @@ -134,10 +126,9 @@ final class ClaudeCodeRefresherTests: XCTestCase { XCTAssertEqual(comps.hour, 23) // 11pm → 23:00 } - // MARK: - AC6 (providers 03): a spawn with no usable output never clobbers + // MARK: - a spawn with no usable output never clobbers func testRefresh_leavesCacheUntouchedWhenNoUsage() async throws { - // Seed a good cache (as the statusline helper would). try StatuslineCacheStore(cacheURL: cacheURL).write( StatuslineCache( writtenAt: 1000, @@ -162,7 +153,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { XCTAssertEqual(cache.rateLimits?.fiveHour?.usedPercentage, 42) } - // MARK: - AC2: a hung process is terminated within the timeout + grace + // MARK: - a hung process is terminated within the timeout + grace func testRefresh_terminatesHungProcess() async throws { let fakeBinary = try writeFakeBinary( @@ -196,7 +187,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { ) } - // MARK: - AC4: debounce skips a second spawn within the window + // MARK: - debounce skips a second spawn within the window func testRefresh_debouncesSecondCallWithinWindow() async throws { let fakeBinary = try writeCountingBinary() @@ -211,8 +202,6 @@ final class ClaudeCodeRefresherTests: XCTestCase { // MARK: - Helpers - /// Writes an executable shell script that records each invocation by - /// appending `counted` to `invocationCountURL`. private func writeCountingBinary() throws -> URL { try writeFakeBinary( name: "fake-claude-count", @@ -223,7 +212,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { ) } - // MARK: - AC4: in-flight callers share one OS process + // MARK: - in-flight callers share one OS process func testRefresh_concurrentCallersShareOneProcess() async throws { // The fake binary sleeps briefly so the two concurrent callers @@ -251,7 +240,7 @@ final class ClaudeCodeRefresherTests: XCTestCase { XCTAssertEqual(count, 1, "Concurrent callers must coalesce onto one spawn") } - // MARK: - AC1 / Risks: binary not found surfaces as a thrown error + // MARK: - binary not found surfaces as a thrown error func testRefresh_throwsBinaryNotFoundWhenLocatorReturnsNil() async throws { let refresher = makeRefresher(binaryPath: nil) @@ -264,18 +253,12 @@ final class ClaudeCodeRefresherTests: XCTestCase { } } - // MARK: - AC4: a failed spawn still suppresses follow-up clicks + // MARK: - a failed spawn still suppresses follow-up clicks func testRefresh_failedSpawnStillDebounces() async throws { - // The locator returns nil, so refresh() throws binaryNotFound. - // The next call within the debounce window should also short-circuit - // (it would be a no-op regardless, but the contract is that the - // debounce timestamp is set on attempt). let refresher = makeRefresher(binaryPath: nil) _ = try? await refresher.refresh() - // Second call should return without throwing because it's debounced - // (returns early before re-attempting the binary lookup). try await refresher.refresh() } @@ -295,8 +278,6 @@ final class ClaudeCodeRefresherTests: XCTestCase { """ } - /// Writes `body` to `/`, `chmod +x`s it, and returns the - /// file URL so it can be handed to `ClaudeCodeLocator(injectedPath:)`. private func writeFakeBinary(name: String, body: String) throws -> URL { let url = tmpDir.appendingPathComponent(name) try body.write(to: url, atomically: true, encoding: .utf8) @@ -307,8 +288,6 @@ final class ClaudeCodeRefresherTests: XCTestCase { return url } - /// Reads the integer written by the counting fake binary. Returns 0 when - /// the file does not exist (i.e. the spawn never ran). private func readInvocationCount() throws -> Int { guard FileManager.default.fileExists(atPath: invocationCountURL.path) else { return 0 diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherWorkingDirectoryTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherWorkingDirectoryTests.swift index 4799a40..dc66974 100644 --- a/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherWorkingDirectoryTests.swift +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeRefresherWorkingDirectoryTests.swift @@ -2,13 +2,6 @@ import Core import XCTest -/// Tests for the working-directory isolation added in (providers 06 AC1). -/// -/// These verify the spawn's CWD behavior specifically — that the child runs in -/// the injected directory rather than the test runner's CWD, and that a -/// directory-creation failure aborts the spawn without clobbering the cache. -/// Kept in a separate file from `ClaudeCodeRefresherTests` to keep each test -/// type under the `type_body_length` threshold. final class ClaudeCodeRefresherWorkingDirectoryTests: XCTestCase { private var tmpDir: URL! private var cacheURL: URL! @@ -33,7 +26,7 @@ final class ClaudeCodeRefresherWorkingDirectoryTests: XCTestCase { super.tearDown() } - // MARK: - AC1 (providers 06): child runs in the injected working directory + // MARK: - child runs in the injected working directory func testRefresh_spawnsInInjectedWorkingDirectory() async throws { // Dedicated temp dir for the child's CWD — distinct from the test @@ -83,10 +76,9 @@ final class ClaudeCodeRefresherWorkingDirectoryTests: XCTestCase { ) } - // MARK: - AC1 (providers 06): directory-creation failure leaves cache untouched + // MARK: - directory-creation failure leaves cache untouched func testRefresh_leavesCacheUntouchedWhenWorkingDirectoryCreationFails() async throws { - // Seed a good cache (as the statusline helper would). try StatuslineCacheStore(cacheURL: cacheURL).write( StatuslineCache( writtenAt: 1000, @@ -129,8 +121,6 @@ final class ClaudeCodeRefresherWorkingDirectoryTests: XCTestCase { // MARK: - Helpers - /// Writes `body` to `/`, `chmod +x`s it, and returns the - /// file URL so it can be handed to `ClaudeCodeLocator(injectedPath:)`. private func writeFakeBinary(name: String, body: String) throws -> URL { let url = tmpDir.appendingPathComponent(name) try body.write(to: url, atomically: true, encoding: .utf8) @@ -141,8 +131,6 @@ final class ClaudeCodeRefresherWorkingDirectoryTests: XCTestCase { return url } - /// Reads the integer written by the counting fake binary. Returns 0 when - /// the file does not exist (i.e. the spawn never ran). private func readInvocationCount(at url: URL) -> Int { guard FileManager.default.fileExists(atPath: url.path) else { return 0 diff --git a/Tests/ClaudeCodeProviderTests/StatuslineCacheStoreTests.swift b/Tests/ClaudeCodeProviderTests/StatuslineCacheStoreTests.swift index 2214011..6e24c90 100644 --- a/Tests/ClaudeCodeProviderTests/StatuslineCacheStoreTests.swift +++ b/Tests/ClaudeCodeProviderTests/StatuslineCacheStoreTests.swift @@ -26,7 +26,7 @@ final class StatuslineCacheStoreTests: XCTestCase { super.tearDown() } - // MARK: - AC5: both windows present + // MARK: - both windows present func testRead_decodesBothWindows() throws { let cache = makeCache( @@ -44,7 +44,7 @@ final class StatuslineCacheStoreTests: XCTestCase { XCTAssertEqual(decoded?.rateLimits?.sevenDay?.resetsAt, 1_713_500_000) } - // MARK: - AC5: only five_hour + // MARK: - only five_hour func testRead_decodesOnlyFiveHour() throws { let cache = makeCache( @@ -58,7 +58,7 @@ final class StatuslineCacheStoreTests: XCTestCase { XCTAssertNil(decoded?.rateLimits?.sevenDay) } - // MARK: - AC5: only seven_day + // MARK: - only seven_day func testRead_decodesOnlySevenDay() throws { let cache = makeCache( @@ -72,7 +72,7 @@ final class StatuslineCacheStoreTests: XCTestCase { XCTAssertEqual(decoded?.rateLimits?.sevenDay?.usedPercentage, 80) } - // MARK: - AC5: no rate_limits (free-tier / new session) + // MARK: - no rate_limits (free-tier / new session) func testRead_decodesNilRateLimits() throws { let json = Data(""" @@ -87,7 +87,7 @@ final class StatuslineCacheStoreTests: XCTestCase { XCTAssertNil(decoded?.rateLimits) } - // MARK: - AC4: absent file returns nil + // MARK: - absent file returns nil func testRead_returnsNilWhenFileAbsent() { XCTAssertNil(store.read()) @@ -108,10 +108,9 @@ final class StatuslineCacheStoreTests: XCTestCase { XCTAssertEqual(decoded?.rateLimits?.sevenDay?.usedPercentage, 60) } - // MARK: - AC7: atomic write (temp + rename) + // MARK: - atomic write (temp + rename) func testAtomicWrite_doesNotLeavePartialFile() throws { - // Pre-write a "current" cache so we can verify it survives a failed write. let original = makeCache( fiveHourPct: 10, fiveHourReset: 1, sevenDayPct: nil, sevenDayReset: nil @@ -120,9 +119,6 @@ final class StatuslineCacheStoreTests: XCTestCase { let originalData = try Data(contentsOf: cacheURL) - // Simulate an updated write. The atomic contract (write to temp, - // then rename) means the original should be replaced atomically — - // the file should never be missing or partial. let updated = makeCache( fiveHourPct: 99, fiveHourReset: 2, sevenDayPct: nil, sevenDayReset: nil @@ -138,8 +134,6 @@ final class StatuslineCacheStoreTests: XCTestCase { // MARK: - Helpers - /// Builds a `StatuslineCache` with optional windows. Pass `nil` for - /// a percentage and a reset to omit that window entirely. private func makeCache( fiveHourPct: Double?, fiveHourReset: TimeInterval?, diff --git a/Tests/ClaudeCodeProviderTests/StatuslineHelperInstallerTests.swift b/Tests/ClaudeCodeProviderTests/StatuslineHelperInstallerTests.swift index 6c04629..792afb0 100644 --- a/Tests/ClaudeCodeProviderTests/StatuslineHelperInstallerTests.swift +++ b/Tests/ClaudeCodeProviderTests/StatuslineHelperInstallerTests.swift @@ -38,10 +38,9 @@ final class StatuslineHelperInstallerTests: XCTestCase { super.tearDown() } - // MARK: - isHelperInstalled (providers 02 AC3) + // MARK: - isHelperInstalled func testIsHelperInstalled_trueWhenExecutableExists() { - // Create a dummy executable at the helper path. try? "#!/bin/sh\necho ok".write(to: helperURL, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes( [.posixPermissions: 0o755], @@ -59,7 +58,7 @@ final class StatuslineHelperInstallerTests: XCTestCase { XCTAssertFalse(installer.isHelperInstalled()) } - // MARK: - AC8: install with no prior statusLine + // MARK: - install with no prior statusLine func testInstall_setsStatusLine_whenNoPriorSettings() throws { try createHelperBinary() @@ -84,7 +83,7 @@ final class StatuslineHelperInstallerTests: XCTestCase { XCTAssertEqual(statusLine?["type"] as? String, "command") } - // MARK: - AC8: install preserves existing statusLine sibling keys + // MARK: - install preserves existing statusLine sibling keys func testInstall_preservesStatusLineSiblingKeys() throws { // A user may have `padding` or `refreshInterval` configured alongside @@ -104,12 +103,13 @@ final class StatuslineHelperInstallerTests: XCTestCase { XCTAssertEqual(statusLine?["padding"] as? Int, 2) XCTAssertEqual(statusLine?["refreshInterval"] as? Int, 5) XCTAssertEqual(statusLine?["type"] as? String, "command") - // Command is now chained, but the original must still be present. let command = statusLine?["command"] as? String ?? "" XCTAssertTrue(command.contains("ccstatusline")) XCTAssertTrue(command.contains(helperURL.path)) } + // MARK: - install normalizes bare string to object form + func testInstall_normalizesBareStringStatusLine_toObjectType() throws { // Claude Code accepts a bare string for statusLine, but our installer // should normalize it to the documented object form with `type`. @@ -123,7 +123,7 @@ final class StatuslineHelperInstallerTests: XCTestCase { XCTAssertEqual(statusLine?["type"] as? String, "command") } - // MARK: - AC8: install chains existing statusLine.command + // MARK: - install chains existing statusLine.command func testInstall_chainsExistingStringCommand() throws { try writeSettingsJSON(["statusLine": "original-cmd"]) @@ -147,28 +147,25 @@ final class StatuslineHelperInstallerTests: XCTestCase { XCTAssertTrue(command?.contains(helperURL.path) ?? false) } - // MARK: - AC8: reinstall replaces in place (no double-wrapping) + // MARK: - reinstall replaces in place (no double-wrapping) func testInstall_replacesExistingChain_whenAlreadyInstalled() throws { - // First install try writeSettingsJSON(["statusLine": "original-cmd"]) try createHelperBinary() try installer.installSettingsOnly() let firstCommand = try extractCommand(from: readSettingsJSON()) ?? "" - // Second install (reinstall) try installer.installSettingsOnly() let secondCommand = try extractCommand(from: readSettingsJSON()) ?? "" - // Should contain the same sentinel count — no double-wrapping. let sentinel = "###FILBERT-CHAIN-START###" let firstCount = firstCommand.components(separatedBy: sentinel).count - 1 let secondCount = secondCommand.components(separatedBy: sentinel).count - 1 XCTAssertEqual(firstCount, secondCount, "Reinstall must not double-wrap the chain") } - // MARK: - AC8: unparseable settings aborts install + // MARK: - unparseable settings aborts install func testInstall_throwsWhenSettingsNotJSON() throws { try "not json {{{".write(to: settingsURL, atomically: true, encoding: .utf8) @@ -178,18 +175,16 @@ final class StatuslineHelperInstallerTests: XCTestCase { } } - // MARK: - AC11: uninstall restores original command + // MARK: - uninstall restores original command func testUninstall_restoresOriginalStringCommand() throws { try writeSettingsJSON(["statusLine": "original-cmd"]) try createHelperBinary() try installer.installSettingsOnly() - // Verify chained let chainedCommand = try extractCommand(from: readSettingsJSON()) ?? "" XCTAssertTrue(chainedCommand.contains("original-cmd")) - // Uninstall try installer.uninstallSettingsOnly() let settings = try readSettingsJSON() @@ -211,7 +206,6 @@ final class StatuslineHelperInstallerTests: XCTestCase { } func testUninstall_removesStatusLine_whenOnlyHelperWasSet() throws { - // No prior statusLine — just our helper. try createHelperBinary() try installer.installSettingsOnly() @@ -226,7 +220,6 @@ final class StatuslineHelperInstallerTests: XCTestCase { func testUninstall_noOp_whenNoStatusLine() throws { try writeSettingsJSON(["otherKey": "value"]) - // No statusLine key at all — uninstall should be a no-op. try installer.uninstallSettingsOnly() let settings = try readSettingsJSON() @@ -239,19 +232,16 @@ final class StatuslineHelperInstallerTests: XCTestCase { try installer.uninstallSettingsOnly() let settings = try readSettingsJSON() - // Different command, not ours — unchanged. let command = extractCommand(from: settings) XCTAssertEqual(command, "some-other-command") } - // MARK: - AC11: uninstall deletes cache file + // MARK: - uninstall deletes cache file func testUninstall_deletesCache() throws { - // Write a dummy cache and a helper binary. try "{}".write(to: cacheURL, atomically: true, encoding: .utf8) try createHelperBinary() - // Full uninstall (not just settings-only). try installer.uninstall() XCTAssertFalse( @@ -260,7 +250,7 @@ final class StatuslineHelperInstallerTests: XCTestCase { ) } - // MARK: - AC11: uninstall deletes helper binary + // MARK: - uninstall deletes helper binary func testUninstall_deletesHelperBinary() throws { try createHelperBinary() @@ -289,8 +279,6 @@ final class StatuslineHelperInstallerTests: XCTestCase { // MARK: - Helpers - /// Creates a dummy executable shell script at the helper destination so - /// `isHelperInstalled()` returns `true`. private func createHelperBinary() throws { let dir = helperURL.deletingLastPathComponent() try FileManager.default.createDirectory( @@ -328,8 +316,6 @@ final class StatuslineHelperInstallerTests: XCTestCase { return json } - /// Extracts the `command` string from a `statusLine` value, which may - /// be a string or a `{"command": "..."}` object. private func extractCommand(from settings: [String: Any]) -> String? { let statusLine = settings["statusLine"] if let str = statusLine as? String { diff --git a/Tests/CoreTests/BalanceThresholdsTests.swift b/Tests/CoreTests/BalanceThresholdsTests.swift index fa1643e..8fd3a7f 100644 --- a/Tests/CoreTests/BalanceThresholdsTests.swift +++ b/Tests/CoreTests/BalanceThresholdsTests.swift @@ -20,7 +20,7 @@ final class BalanceThresholdsTests: XCTestCase { super.tearDown() } - // MARK: - AC2: defaults when unset + // MARK: - defaults when unset func testLow_returnsDefaultWhenUnset() { XCTAssertEqual(BalanceThresholds.low, 5) @@ -30,7 +30,7 @@ final class BalanceThresholdsTests: XCTestCase { XCTAssertEqual(BalanceThresholds.ok, 20) } - // MARK: - AC2: round-trip + // MARK: - round-trip func testSet_persistsAndReadsBack() { BalanceThresholds.set(low: 10, ok: 50) @@ -39,7 +39,7 @@ final class BalanceThresholdsTests: XCTestCase { XCTAssertEqual(BalanceThresholds.ok, 50) } - // MARK: - AC2: clamps ok upward so ok > low always holds + // MARK: - clamps ok upward so ok > low always holds func testSet_clampsOkUpwardWhenEqualToLow() { BalanceThresholds.set(low: 10, ok: 10) @@ -55,12 +55,11 @@ final class BalanceThresholdsTests: XCTestCase { XCTAssertEqual(BalanceThresholds.ok, 21) } - // MARK: - AC2: rejects negative low by ignoring the write + // MARK: - rejects negative low by ignoring the write func testSet_rejectsNegativeLow() { BalanceThresholds.set(low: -5, ok: 20) - // Nothing persisted — defaults remain. XCTAssertEqual(BalanceThresholds.low, 5) XCTAssertEqual(BalanceThresholds.ok, 20) } @@ -72,7 +71,7 @@ final class BalanceThresholdsTests: XCTestCase { XCTAssertEqual(BalanceThresholds.ok, 10) } - // MARK: - AC2: survives relaunch (new instance over the same suite) + // MARK: - survives relaunch (new instance over the same suite) func testSet_survivesRelaunch() throws { BalanceThresholds.set(low: 7, ok: 42) diff --git a/Tests/CoreTests/KeychainTests.swift b/Tests/CoreTests/KeychainTests.swift index 763b173..e507cb6 100644 --- a/Tests/CoreTests/KeychainTests.swift +++ b/Tests/CoreTests/KeychainTests.swift @@ -7,7 +7,6 @@ final class KeychainTests: XCTestCase { private let currentService = "filbert" func testKeychainError_casesExist() { - // Verify KeychainError cases exist and are distinct. // We can't test real Keychain I/O in CI, but the enum must compile. let errors: [KeychainError] = [ .saveFailed(-1), @@ -54,7 +53,7 @@ final class KeychainTests: XCTestCase { XCTAssertEqual(error as? KeychainError, .loadFailed(errSecDecode)) } // The store is untouched — recovery happens via the normal setup flow, - // not an in-place rewrite (core 06 AC2). + // not an in-place rewrite. XCTAssertNotNil(storage.items[currentService]?["providers"]) XCTAssertTrue(storage.deletedItems.isEmpty) } @@ -85,8 +84,6 @@ final class KeychainTests: XCTestCase { } func testFailedUpdatePreservesCachedAndStoredFields() throws { - // core 07 AC2: a failed write returns a typed error and leaves both - // the in-memory cache and the on-disk item at their pre-write state. let storage = InMemoryKeychainStorage() let original = ["zai": ["value": "zai-key"], "deepseek": ["value": "deepseek-key"]] let originalData = try JSONEncoder().encode(original) @@ -118,8 +115,6 @@ final class KeychainTests: XCTestCase { } func testKeychainStorageTypesArePublicAcrossModuleBoundary() { - // core 07 AC3: provider modules see the storage protocol, the - // concrete accessor, the shared context, and its error as `public`. // Compile-time assertion only — no runtime behavior to exercise. let storage: any KeychainStorage = SecurityKeychainStorage() let context = KeychainAuthenticationContext() @@ -131,8 +126,6 @@ final class KeychainTests: XCTestCase { } func testSharedAuthenticationContextIsReusedAcrossAccesses() { - // core 07 AC1: every Keychain access in the session routes through - // the same `LAContext`, created lazily on first use. let first = KeychainAuthenticationContext.shared.localAuthenticationContext let second = KeychainAuthenticationContext.shared.localAuthenticationContext @@ -140,8 +133,8 @@ final class KeychainTests: XCTestCase { } func testInvalidateSwapsUnderlyingLAContext() { - // core 07 AC5: sleep/wake/lock swap the underlying `LAContext` so - // macOS can re-prompt within its new authorization window. + // sleep/wake/lock swap the underlying `LAContext` so macOS can + // re-prompt within its new authorization window. let context = KeychainAuthenticationContext() let before = context.localAuthenticationContext diff --git a/Tests/CoreTests/ProviderOrderTests.swift b/Tests/CoreTests/ProviderOrderTests.swift index 184254e..620b60f 100644 --- a/Tests/CoreTests/ProviderOrderTests.swift +++ b/Tests/CoreTests/ProviderOrderTests.swift @@ -20,7 +20,7 @@ final class ProviderOrderTests: XCTestCase { super.tearDown() } - // MARK: - AC4: empty saved order → input order preserved + // MARK: - empty saved order → input order preserved func testEffectiveOrder_returnsInputOrderWhenUnset() { XCTAssertEqual( @@ -30,19 +30,17 @@ final class ProviderOrderTests: XCTestCase { } func testEffectiveOrder_preservesInputOrderForUnsavedIds() { - // No saved order — input order is the fallback. XCTAssertEqual( ProviderOrder.effectiveOrder(for: ["deepseek", "zai"]), ["deepseek", "zai"] ) } - // MARK: - AC5: partial saved order → saved-first then unsaved + // MARK: - partial saved order → saved-first then unsaved func testEffectiveOrder_putsSavedIdsFirstInSavedSequence() { ProviderOrder.setOrder(["claude", "zai"]) - // Unsaved "deepseek" keeps its input position after the saved pair. XCTAssertEqual( ProviderOrder.effectiveOrder(for: ["deepseek", "zai", "claude"]), ["claude", "zai", "deepseek"] @@ -58,7 +56,7 @@ final class ProviderOrderTests: XCTestCase { ) } - // MARK: - AC5: stale saved IDs → dropped on read + // MARK: - stale saved IDs → dropped on read func testEffectiveOrder_dropsSavedIdsNoLongerRegistered() { ProviderOrder.setOrder(["claude", "zai", "ghost"]) @@ -69,19 +67,18 @@ final class ProviderOrderTests: XCTestCase { ) } - // MARK: - AC5: appending newly registered providers + // MARK: - appending newly registered providers func testEffectiveOrder_appendsNewlyRegisteredIdsAfterSavedOnes() { ProviderOrder.setOrder(["claude", "zai"]) - // "deepseek" is newly registered — appended in input order. XCTAssertEqual( ProviderOrder.effectiveOrder(for: ["claude", "zai", "deepseek"]), ["claude", "zai", "deepseek"] ) } - // MARK: - AC8: setOrder round-trip via savedOrder() + // MARK: - setOrder round-trip via savedOrder() func testSavedOrder_returnsNilWhenUnset() { XCTAssertNil(ProviderOrder.savedOrder()) @@ -119,8 +116,6 @@ final class ProviderOrderTests: XCTestCase { } func testEffectiveOrder_emptySavedOrderListBehavesLikeUnset() { - // An explicit empty list is a valid stored value — should behave like - // "no saved order" (input order preserved). ProviderOrder.setOrder([]) XCTAssertNotNil(ProviderOrder.savedOrder()) diff --git a/Tests/CoreTests/ProviderOverridesTests.swift b/Tests/CoreTests/ProviderOverridesTests.swift index 938ce1b..7a27de6 100644 --- a/Tests/CoreTests/ProviderOverridesTests.swift +++ b/Tests/CoreTests/ProviderOverridesTests.swift @@ -20,7 +20,7 @@ final class ProviderOverridesTests: XCTestCase { super.tearDown() } - // MARK: - AC4/AC5: round-trip and clearing + // MARK: - round-trip and clearing func testBaseURL_returnsNilWhenUnset() { XCTAssertNil(ProviderOverrides.baseURL(for: "zai")) @@ -52,7 +52,7 @@ final class ProviderOverridesTests: XCTestCase { XCTAssertEqual(ProviderOverrides.baseURL(for: "claude"), claude) } - // MARK: - AC5: only https is accepted on write + // MARK: - only https is accepted on write func testSetBaseURL_rejectsHttp() throws { let http = try XCTUnwrap(URL(string: "http://proxy.example.com")) @@ -72,7 +72,7 @@ final class ProviderOverridesTests: XCTestCase { XCTAssertNil(ProviderOverrides.baseURL(for: "zai")) } - // MARK: - AC6: invalid stored values fall back to nil (defense in depth) + // MARK: - invalid stored values fall back to nil (defense in depth) func testBaseURL_treatsStoredHttpAsUnsetAndCleansUp() { defaults.set("http://proxy.example.com", forKey: "provider-zai-base-url") diff --git a/Tests/CoreTests/ProviderProtocolTests.swift b/Tests/CoreTests/ProviderProtocolTests.swift index 0b116de..05324a1 100644 --- a/Tests/CoreTests/ProviderProtocolTests.swift +++ b/Tests/CoreTests/ProviderProtocolTests.swift @@ -64,7 +64,7 @@ final class ProviderProtocolTests: XCTestCase { XCTAssertEqual(detail.value, "42 / 500") } - // MARK: - ProviderInfo (ui 05 AC1) + // MARK: - ProviderInfo func testProviderInfo_includesAuthShape() throws { let info = try ProviderInfo( @@ -156,7 +156,7 @@ final class ProviderProtocolTests: XCTestCase { XCTAssertEqual(name, "cpu") } - // MARK: - ProviderSetupError (ui 05) + // MARK: - ProviderSetupError func testProviderSetupError_notSupported_isEquatable() { XCTAssertEqual( diff --git a/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift b/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift index 3c85c39..be00966 100644 --- a/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift +++ b/Tests/CoreTests/ProviderRegistryProactiveRefreshTests.swift @@ -1,17 +1,9 @@ import Core import XCTest -/// Tests for the new `ProactiveRefreshable` routing in `ProviderRegistry` -/// (providers 03 AC3, AC7). -/// -/// The registry tests are scoped to the proactive-refresh surface added by -/// (providers 03). Broader registry coverage (`fetchAll`, `isConfigured`, -/// etc.) is exercised end-to-end by the provider test suites. -/// `ProviderRegistry` is `@MainActor` (ci 04 Plan §4), so every test that -/// constructs and exercises it runs on the main actor too. @MainActor final class ProviderRegistryProactiveRefreshTests: XCTestCase { - // MARK: - AC3: routes to a conforming provider + // MARK: - routes to a conforming provider func testProactiveRefresh_routesToConformingProvider() async throws { let registry = ProviderRegistry() @@ -23,7 +15,7 @@ final class ProviderRegistryProactiveRefreshTests: XCTestCase { XCTAssertTrue(provider.refreshCalled, "proactiveRefresh(for:) must delegate to the conforming provider") } - // MARK: - AC7: non-conforming providers throw `.notSupported` + // MARK: - non-conforming providers throw `.notSupported` func testProactiveRefresh_throwsNotSupported_forNonConformingProvider() async { let registry = ProviderRegistry() @@ -84,9 +76,8 @@ final class ProviderRegistryProactiveRefreshTests: XCTestCase { // MARK: - Test fixtures -/// A minimal `AIProvider` that also conforms to `ProactiveRefreshable`, so -/// the registry can route `proactiveRefresh(for:)` to it. `class` so the -/// `refreshCalled` flag can mutate through the registry's stored reference. +/// `class` so the `refreshCalled` flag can mutate through the registry's +/// stored reference. private final class FakeProactiveRefreshProvider: AIProvider, ProactiveRefreshable, @unchecked Sendable { static let providerId = "fake-refreshable" static let providerName = "Fake Refreshable" @@ -111,8 +102,6 @@ private final class FakeProactiveRefreshProvider: AIProvider, ProactiveRefreshab } } -/// A minimal `AIProvider` that does NOT conform to `ProactiveRefreshable`, -/// so the registry reports `.notSupported` for it (providers 03 AC7). private struct FakeNonRefreshableProvider: AIProvider { static let providerId = "fake-non-refreshable" static let providerName = "Fake Non-Refreshable" diff --git a/Tests/CursorProviderTests/CursorExternalAccessorTests.swift b/Tests/CursorProviderTests/CursorExternalAccessorTests.swift index 00edf07..dab41d2 100644 --- a/Tests/CursorProviderTests/CursorExternalAccessorTests.swift +++ b/Tests/CursorProviderTests/CursorExternalAccessorTests.swift @@ -4,10 +4,6 @@ import Foundation import Security import XCTest -/// Tests for `CursorTokenStore`'s use of Core's shared Keychain accessor -/// (core 07 AC4/AC6). These belong in their own file because they exercise -/// the public Core storage/context types directly rather than the -/// closure-based test seams used by the bootstrap tests. final class CursorExternalAccessorTests: XCTestCase { func testBootstrapReadsExternalPairViaSharedAccessor() throws { let vault = TestCursorCredentialVault() @@ -28,7 +24,6 @@ final class CursorExternalAccessorTests: XCTestCase { XCTAssertEqual(pair?.accessToken, "token") XCTAssertEqual(pair?.refreshToken, "token") - // core 07 AC4: external reads route through the shared Core context. XCTAssertTrue(contexts.read().allSatisfy { $0 == ObjectIdentifier(KeychainAuthenticationContext.shared) }) @@ -101,7 +96,7 @@ final class CursorExternalAccessorTests: XCTestCase { ) // Absence (errSecItemNotFound) is not an error; it falls through to - // SQLite and ultimately returns nil (core 05 AC4, core 07 AC4). + // SQLite and ultimately returns nil. XCTAssertNil(try store.loadOrBootstrap()) } diff --git a/Tests/CursorProviderTests/CursorProviderMetadataTests.swift b/Tests/CursorProviderTests/CursorProviderMetadataTests.swift index 17e305a..03d4242 100644 --- a/Tests/CursorProviderTests/CursorProviderMetadataTests.swift +++ b/Tests/CursorProviderTests/CursorProviderMetadataTests.swift @@ -4,7 +4,7 @@ import Foundation import XCTest final class CursorProviderMetadataTests: XCTestCase { - // MARK: - AC1: provider-owned glyph (providers 07) + // MARK: - provider-owned glyph func testProviderGlyphLoadsFromModuleResources() { guard case let .asset(name, bundle) = CursorProvider.providerGlyph else { @@ -16,7 +16,7 @@ final class CursorProviderMetadataTests: XCTestCase { XCTAssertNotNil(bundle.url(forResource: "\(name)@2x", withExtension: "png")) } - // MARK: - AC2b: external login prerequisite (providers 07) + // MARK: - external login prerequisite @MainActor func testSetupHelpPointsToCursorCLIAuthenticationDocs() throws { @@ -41,7 +41,7 @@ final class CursorProviderMetadataTests: XCTestCase { XCTAssertEqual(CursorProvider.credentialImportActionTitle, "Re-import Cursor credentials") } - // MARK: - AC10: setup state (providers 07) + // MARK: - setup state func testSetupState_missingBinaryAndToken_showsInstallMessage() async { let provider = makeProvider(token: nil, binaryExists: false) @@ -78,7 +78,7 @@ final class CursorProviderMetadataTests: XCTestCase { XCTAssertNil(state) } - // MARK: - AC1/AC2: credential removal (bugs 01) + // MARK: - credential removal func testRemoveHelper_clearsStoredCredentialsAndReportsUnconfigured() async throws { let vault = TestCursorCredentialVault(fields: [ @@ -108,7 +108,7 @@ final class CursorProviderMetadataTests: XCTestCase { XCTAssertFalse(provider.isConfigured()) } - // MARK: - AC3: idempotent removal (bugs 01) + // MARK: - idempotent removal func testRemoveHelper_isIdempotentWhenVaultEmpty() async throws { let vault = TestCursorCredentialVault() diff --git a/Tests/CursorProviderTests/CursorProviderTests.swift b/Tests/CursorProviderTests/CursorProviderTests.swift index 06e8ecf..f55566d 100644 --- a/Tests/CursorProviderTests/CursorProviderTests.swift +++ b/Tests/CursorProviderTests/CursorProviderTests.swift @@ -4,7 +4,7 @@ import Foundation import XCTest final class CursorProviderTests: XCTestCase { - // MARK: - AC6: authenticated Connect-RPC request shape (providers 07) + // MARK: - authenticated Connect-RPC request shape func testFetchQuota_issuesCorrectRequest() async throws { let provider = makeProviderWithMock(usageBody: CursorTestFixtures.usageResponse()) @@ -36,7 +36,7 @@ final class CursorProviderTests: XCTestCase { ) } - // MARK: - AC7: plan usage mapping (providers 07) + // MARK: - plan usage mapping func testFetchQuota_mapsPlanUsageToPercentageAndCurrencyLines() async throws { let quota = try await fetchWithMock(CursorTestFixtures.usageResponse()) @@ -89,7 +89,7 @@ final class CursorProviderTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(onDemandLine.total), 20.0, accuracy: 0.01) } - // MARK: - AC8: on-demand and pooled spend (providers 07) + // MARK: - on-demand and pooled spend func testFetchQuota_addsOnDemandSpendLine() async throws { let quota = try await fetchWithMock(CursorTestFixtures.usageResponse()) @@ -120,7 +120,7 @@ final class CursorProviderTests: XCTestCase { XCTAssertEqual(pooledLine.unit, "USD") } - // MARK: - AC9: headline (providers 07) + // MARK: - headline func testFetchQuota_headlineShowsRemainingAmountWithCountdown() async throws { let quota = try await fetchWithMock(CursorTestFixtures.usageResponse()) @@ -150,7 +150,7 @@ final class CursorProviderTests: XCTestCase { XCTAssertEqual(quota.headline, "No data") } - // MARK: - AC10: typed errors (providers 07) + // MARK: - typed errors func testFetchQuota_throwsMissingTokenWhenNoToken() async { let provider = makeProvider(token: nil, binaryExists: true) diff --git a/Tests/CursorProviderTests/CursorTestFixtures.swift b/Tests/CursorProviderTests/CursorTestFixtures.swift index 5473200..b8faae0 100644 --- a/Tests/CursorProviderTests/CursorTestFixtures.swift +++ b/Tests/CursorProviderTests/CursorTestFixtures.swift @@ -2,11 +2,9 @@ @testable import CursorProvider import Foundation -/// Shared test fixtures and helpers for Cursor provider tests (providers 07). enum CursorTestFixtures { // MARK: - JWT builder - /// Builds a JWT string with the given `exp` claim (seconds since epoch). static func makeJWT(exp: TimeInterval) -> String { let header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}" let payload = "{\"exp\":\(Int(exp))}" @@ -151,9 +149,9 @@ final class LockedBox: @unchecked Sendable { } } -/// Test-only `KeychainStorage` that drives external reads from a closure. -/// Used to inject canned Keychain responses into `CursorTokenStore` -/// without touching the real Keychain (core 07 AC6). +/// Test-only `KeychainStorage` that drives external reads from a closure, +/// used to inject canned Keychain responses into `CursorTokenStore` +/// without touching the real Keychain. final class ClosureKeychainStorage: KeychainStorage, @unchecked Sendable { private let read: @Sendable (String, String, KeychainAuthenticationContext) throws -> Data? diff --git a/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift b/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift index b4b0597..7f4cc81 100644 --- a/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift +++ b/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift @@ -24,19 +24,17 @@ final class DeepSeekProviderTests: XCTestCase { super.tearDown() } - /// Convenience: fetch with the default test key and default base URL. private func fetchWithDefaultBaseURL() async throws -> ProviderQuota { try await provider.fetchQuota(auth: .apiKey("test-key"), baseURL: DeepSeekProvider.baseURL) } - /// Convenience: stage a mock response + 200 status, then fetch. private func fetchWithMock(_ data: Data) async throws -> ProviderQuota { MockURLProtocol.responseData = data MockURLProtocol.responseStatusCode = 200 return try await fetchWithDefaultBaseURL() } - // MARK: - AC1: Authenticated request + // MARK: - Authenticated request func testFetchQuota_issuesCorrectRequest() async throws { _ = try await fetchWithMock(validResponseJSON()) @@ -60,7 +58,7 @@ final class DeepSeekProviderTests: XCTestCase { XCTAssertEqual(request.url?.absoluteString, "https://deepseek-proxy.example.com/user/balance") } - // MARK: - AC2: Balance maps to currency-tagged UsageLines + // MARK: - Balance maps to currency-tagged UsageLines func testFetchQuota_mapsBalanceInfosToLines() async throws { let quota = try await fetchWithMock(validResponseJSON()) @@ -96,7 +94,7 @@ final class DeepSeekProviderTests: XCTestCase { } } - // MARK: - AC3: is_available: false surfaced explicitly + // MARK: - is_available: false surfaced explicitly func testFetchQuota_unavailableHeadlineWhenIsAvailableFalse() async throws { let json = Data(""" @@ -115,11 +113,11 @@ final class DeepSeekProviderTests: XCTestCase { let quota = try await fetchWithMock(json) XCTAssertEqual(quota.headline, "No balance available") - // AC3: lines are still returned so the user can see what's left. + // Lines are still returned so the user can see what's left. XCTAssertEqual(quota.lines.count, 3) } - // MARK: - AC4: Headline shows total balance, currency-aware + // MARK: - Headline shows total balance, currency-aware func testFetchQuota_headlineFormatsTotalBalanceWithCurrencySymbol() async throws { let quota = try await fetchWithMock(validResponseJSON()) @@ -150,7 +148,7 @@ final class DeepSeekProviderTests: XCTestCase { XCTAssertEqual(quota.lines.count, 0) } - // MARK: - AC5: Failures surface as typed errors + // MARK: - Failures surface as typed errors func testFetchQuota_throwsMissingKeyForEmptyKey() async throws { do { @@ -184,7 +182,6 @@ final class DeepSeekProviderTests: XCTestCase { XCTFail("Expected network error") } catch let error as DeepSeekError { if case .network = error { - // expected } else { XCTFail("Expected .network error, got \(error)") } @@ -200,14 +197,13 @@ final class DeepSeekProviderTests: XCTestCase { XCTFail("Expected decoding error") } catch let error as DeepSeekError { if case .decoding = error { - // expected } else { XCTFail("Expected .decoding error, got \(error)") } } } - // MARK: - AC5: 401 → localized "Authentication failed"; 429 → "Rate limited" + // MARK: - 401 → localized "Authentication failed"; 429 → "Rate limited" func testDeepSeekError_401MapsToAuthenticationFailed() { XCTAssertEqual( @@ -223,7 +219,7 @@ final class DeepSeekProviderTests: XCTestCase { ) } - // MARK: - core 03 AC3: internal-consistency assertion + // MARK: - internal-consistency assertion func testFetchQuota_throwsInternalInconsistencyForApiKeyFree() async throws { do { @@ -234,7 +230,7 @@ final class DeepSeekProviderTests: XCTestCase { } } - // MARK: - core 03 AC6: currentSetupState returns nil for .apiKey providers + // MARK: - currentSetupState returns nil for .apiKey providers func testCurrentSetupState_returnsNil() async { let state = await provider.currentSetupState() diff --git a/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift b/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift index cbd6938..5d67971 100644 --- a/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift +++ b/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift @@ -16,7 +16,7 @@ final class OpenAICodexProviderTests: XCTestCase { try FileManager.default.removeItem(at: temporaryDirectory) } - // MARK: - AC1 (providers 05): locator ordering + // MARK: - locator ordering func testLocator_prefersPATHBeforeKnownLocations() { let expectedPath = "/custom/bin/codex" @@ -37,7 +37,7 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertNil(locator.resolve()) } - // MARK: - AC2 (providers 05): API-key-free setup + // MARK: - API-key-free setup func testProvider_reportsMissingCLIAsSetupState() async { let provider = makeProvider(executablePath: nil) @@ -67,7 +67,7 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertEqual(info.setupHelp, try XCTUnwrap(OpenAICodexProvider.setupHelp)) } - // MARK: - AC3/AC8 (providers 05): protocol correlation and recoveries + // MARK: - protocol correlation and recoveries func testClient_ignoresNotificationsAndReturnsMatchingRateLimitResponse() async throws { let rateLimitResult = "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":" @@ -133,7 +133,7 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertLessThan(Date().timeIntervalSince(start), 2) } - // MARK: - AC5–AC7 (providers 05): snapshot mapping + // MARK: - snapshot mapping func testProvider_prefersCodexBucketAndMapsWindowsCreditsAndHeadline() { let provider = makeProvider(executablePath: nil) @@ -185,7 +185,7 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertEqual(quota.lines[0].details?.first?.value, "Unlimited credits") } - // MARK: - AC9 (providers 05): coalesced fetches + // MARK: - coalesced fetches func testProvider_coalescesConcurrentFetches() async throws { let countURL = temporaryDirectory.appendingPathComponent("count") diff --git a/Tests/ZAIProviderTests/ZAIProviderTests.swift b/Tests/ZAIProviderTests/ZAIProviderTests.swift index 2f1aac4..f78f885 100644 --- a/Tests/ZAIProviderTests/ZAIProviderTests.swift +++ b/Tests/ZAIProviderTests/ZAIProviderTests.swift @@ -23,7 +23,7 @@ final class ZAIProviderTests: XCTestCase { super.tearDown() } - // MARK: - AC1: Authenticated request + // MARK: - Authenticated request func testFetchQuota_issuesCorrectRequest() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -42,7 +42,7 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(request?.value(forHTTPHeaderField: "Accept"), "application/json") } - // MARK: - AC8: custom base URL (proxy) is honored (core 02) + // MARK: - custom base URL (proxy) is honored func testFetchQuota_usesCustomBaseURLWhenProvided() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -62,7 +62,7 @@ final class ZAIProviderTests: XCTestCase { ) } - // MARK: - AC2: Known (type, unit) → labelled UsageLine + // MARK: - Known (type, unit) → labelled UsageLine func testFetchQuota_mapsKnownTypeUnitPairs() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -149,7 +149,7 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(quota.lines[0].label, "5-hour window") } - // MARK: - AC3: nextResetTime → resetDate + // MARK: - nextResetTime → resetDate func testFetchQuota_convertsEpochMsToDate() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -167,7 +167,7 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(resetDate.timeIntervalSince1970, expected.timeIntervalSince1970, accuracy: 1) } - // MARK: - AC4: usageDetails → UsageDetail rows + // MARK: - usageDetails → UsageDetail rows func testFetchQuota_mapsUsageDetails() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -206,7 +206,7 @@ final class ZAIProviderTests: XCTestCase { XCTAssertNil(quota.lines[0].details) } - // MARK: - AC5: Headline priority (5-hour → weekly) + // MARK: - Headline priority (5-hour → weekly) func testFetchQuota_headlineUsesFiveHourPriority() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -263,7 +263,7 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(quota.headline, "No data") } - // MARK: - AC6: Failures surface as typed errors + // MARK: - Failures surface as typed errors func testFetchQuota_throwsMissingKeyForEmptyKey() async throws { do { @@ -303,7 +303,6 @@ final class ZAIProviderTests: XCTestCase { XCTFail("Expected network error") } catch let error as ZAIError { if case .network = error { - // expected } else { XCTFail("Expected .network error, got \(error)") } @@ -322,14 +321,13 @@ final class ZAIProviderTests: XCTestCase { XCTFail("Expected decoding error") } catch let error as ZAIError { if case .decoding = error { - // expected } else { XCTFail("Expected .decoding error, got \(error)") } } } - // MARK: - core 03 AC3: internal-consistency assertion + // MARK: - internal-consistency assertion func testFetchQuota_throwsInternalInconsistencyForApiKeyFree() async throws { MockURLProtocol.responseData = validResponseJSON() @@ -346,7 +344,7 @@ final class ZAIProviderTests: XCTestCase { } } - // MARK: - core 03 AC6: currentSetupState returns nil for .apiKey providers + // MARK: - currentSetupState returns nil for .apiKey providers func testCurrentSetupState_returnsNil() async { let state = await provider.currentSetupState()