---
name: tddworks/add-provider
source: https://app.decimal.ai/s/tddworks-add-provider@1/SKILL.md
source_sha256: c5485fb72d2a
---

# Add Provider to ClaudeBar

Add new AI providers following established TDD patterns and architecture.

## Architecture Overview

> **Full architecture:** [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md)

| Component | Location | Purpose |
|-----------|----------|---------|
| `AIProvider` | `Sources/Domain/Provider/` | Rich domain model with isEnabled state |
| `UsageProbe` | `Sources/Infrastructure/CLI/` | Fetches quota from CLI/API |
| Tests | `Tests/InfrastructureTests/CLI/` | Parsing + behavior tests |

## TDD Workflow

### Phase 1: Parsing Tests (Red → Green)

Create `Tests/InfrastructureTests/CLI/{Provider}UsageProbeParsingTests.swift`:

```swift
import Testing
import Foundation
@testable import Infrastructure
@testable import Domain

@Suite
struct {Provider}UsageProbeParsingTests {

    static let sampleResponse = """
                                { /* sample API/CLI response */ }
                                """

    @Test func `parses quota into UsageQuota`() throws {
        let data = Data(Self.sampleResponse.utf8)
        let snapshot = try {Provider}UsageProbe.parseResponse(data, providerId: "{provider-id}")
        #expect(snapshot.quotas.count > 0)
    }

    @Test func `maps percentage correctly`() throws { /* ... */ }
    @Test func `parses reset time`() throws { /* ... */ }
    @Test func `extracts account email`() throws { /* ... */ }
    @Test func `handles missing data gracefully`() throws { /* ... */ }
}
```

### Phase 2: Probe Behavior Tests (Red → Green)

Create `Tests/InfrastructureTests/CLI/{Provider}UsageProbeTests.swift`:

```swift
import Testing
import Foundation
import Mockable
@testable import Infrastructure
@testable import Domain

@Suite
struct {Provider}UsageProbeTests {

    @Test func `isAvailable returns false when not detected`() async {
        let mockExecutor = MockCLIExecutor()
        given(mockExecutor).execute(...).willReturn(CLIResult(output: "", exitCode: 1))
        let probe = {Provider}UsageProbe(cliExecutor: mockExecutor)
        #expect(await probe.isAvailable() == false)
    }

    @Test func `isAvailable returns true when detected`() async { /* ... */ }
    @Test func `probe throws appropriate error when unavailable`() async { /* ... */ }
    @Test func `probe returns UsageSnapshot on success`() async { /* ... */ }
}
```

### Phase 3: Implement Probe

Create `Sources/Infrastructure/CLI/{Provider}UsageProbe.swift`:

```swift
import Foundation
import Domain

public struct {Provider}UsageProbe: UsageProbe {
    private let cliExecutor: any CLIExecutor
    private let networkClient: any NetworkClient
    private let timeout: TimeInterval

    public init(
        cliExecutor: (any CLIExecutor)? = nil,
        networkClient: (any NetworkClient)? = nil,
        timeout: TimeInterval = 8.0
    ) {
        self.cliExecutor = cliExecutor ?? DefaultCLIExecutor()
        self.networkClient = networkClient ?? URLSession.shared
        self.timeout = timeout
    }

    public func isAvailable() async -> Bool {
        // Detect if provider is available (binary exists, process running, etc.)
    }

    public func probe() async throws -> UsageSnapshot {
        // 1. Detect/authenticate
        // 2. Fetch quota data
        // 3. Parse and return UsageSnapshot
    }

    // Static parsing for testability
    static func parseResponse(_ data: Data, providerId: String) throws -> UsageSnapshot {
        // Parse response into domain models
    }
}
```

### Phase 4: Create Provider

**Choose Repository Type (ISP):**
- **Simple provider** (no special config) → Use base `ProviderSettingsRepository`
- **Provider with config** → Create sub-protocol extending base (see ISP section below)

Create `Sources/Domain/Provider/{Provider}Provider.swift`:

```swift
import Foundation
import Observation

@Observable
public final class {Provider}Provider: AIProvider, @unchecked Sendable {
    public let id: String = "{provider-id}"
    public let name: String = "{Provider Name}"
    public let cliCommand: String = "{cli-command}"

    public var dashboardURL: URL? { URL(string: "https://...") }
    public var statusPageURL: URL? { nil }

    /// Whether the provider is enabled (persisted via settingsRepository)
    public var isEnabled: Bool {
        didSet {
            settingsRepository.setEnabled(isEnabled, forProvider: id)
        }
    }

    public private(set) var isSyncing: Bool = false
    public private(set) var snapshot: UsageSnapshot?
    public private(set) var lastError: Error?

    private let probe: any UsageProbe
    private let settingsRepository: any ProviderSettingsRepository  // Or your sub-protocol

    public init(probe: any UsageProbe, settingsRepository: any ProviderSettingsRepository) {
        self.probe = probe
        self.settingsRepository = settingsRepository
        // Default to enabled for most providers (set defaultValue: false for opt-in providers)
        self.isEnabled = settingsRepository.isEnabled(forProvider: "{provider-id}")
    }

    public func isAvailable() async -> Bool {
        await probe.isAvailable()
    }

    @discardableResult
    public func refresh() async throws -> UsageSnapshot {
        isSyncing = true
        defer { isSyncing = false }
        do {
            let newSnapshot = try await probe.probe()
            snapshot = newSnapshot
            lastError = nil
            return newSnapshot
        } catch {
            lastError = error
            throw error
        }
    }
}
```

### Phase 5: Register Provider

Add to `Sources/App/ClaudeBarApp.swift`:

```swift
let settingsRepository = JSONSettingsRepository.shared

let repository = AIProviders(providers: [
    ClaudeProvider(probe: ClaudeUsageProbe(), settingsRepository: settingsRepository),
    // ... existing providers
    {Provider}Provider(probe: {Provider}UsageProbe(), settingsRepository: settingsRepository),
])
```

**For providers with special settings (ISP pattern):**

```swift
// ZaiProvider uses ZaiSettingsRepository (sub-protocol)
ZaiProvider(
    probe: ZaiUsageProbe(settingsRepository: settingsRepository),
    settingsRepository: settingsRepository  // Same instance, casted to ZaiSettingsRepository
)

// CopilotProvider uses CopilotSettingsRepository (sub-protocol with credentials)
CopilotProvider(
    probe: CopilotUsageProbe(settingsRepository: settingsRepository),
    settingsRepository: settingsRepository  // Same instance, casted to CopilotSettingsRepository
)
```

Add visual identity in `Sources/App/Views/Theme.swift`:

```swift
// In AppTheme.providerColor(for:scheme:)
case "{provider-id}": return /* your color */

// In AppTheme.providerName(for:)
case "{provider-id}": return "{Provider Name}"

// In AppTheme.providerSymbolIcon(for:)
case "{provider-id}": return "/* SF Symbol name */"

// In AppTheme.providerIconAssetName(for:)
case "{provider-id}": return "{Provider}Icon"
```

## Domain Model Mapping

Map provider responses to existing domain models:

| Source Data | Domain Model |
|-------------|--------------|
| Quota percentage | `UsageQuota.percentRemaining` (0-100) |
| Model/tier name | `QuotaType.modelSpecific("name")` |
| Reset time | `UsageQuota.resetsAt` (Date) |
| Account email | `UsageSnapshot.accountEmail` |

## Error Handling

Use existing `ProbeError` enum:

```swift
ProbeError.cliNotFound("{Provider}")      // Binary/process not found
ProbeError.authenticationRequired          // Auth token missing/expired
ProbeError.executionFailed("message")      // Runtime errors
ProbeError.parseFailed("message")          // Parse errors
```

## ISP: Creating Provider-Specific Repository Sub-Protocols

If your provider needs special configuration or credentials, create a sub-protocol following ISP:

### Step 1: Define Sub-Protocol in Domain

Add to `Sources/Domain/Provider/ProviderSettingsRepository.swift`:

```swift
/// {Provider}-specific settings repository, extending base ProviderSettingsRepository.
public protocol {Provider}SettingsRepository: ProviderSettingsRepository {
    // Configuration
    func {provider}ConfigPath() -> String
    func set{Provider}ConfigPath(_ path: String)

    // Credentials (if needed)
    func save{Provider}Token(_ token: String)
    func get{Provider}Token() -> String?
    func has{Provider}Token() -> Bool
}
```

### Step 2: Implement in Infrastructure

Add to `Sources/Infrastructure/Storage/JSONSettingsRepository.swift`:

```swift
// MARK: - {Provider}SettingsRepository

extension JSONSettingsRepository: {Provider}SettingsRepository {
    public func {provider}ConfigPath() -> String {
        store.read(key: "{provider}.configPath") ?? ""
    }

    public func set{Provider}ConfigPath(_ path: String) {
        store.write(value: path, key: "{provider}.configPath")
    }
}
```

### Step 3: Update Provider and Probe

```swift
// Provider
private let settingsRepository: any {Provider}SettingsRepository

// Probe (if needs settings)
public init(settingsRepository: any {Provider}SettingsRepository) {
    self.settingsRepository = settingsRepository
}
```

**Existing Examples:**
- `ZaiSettingsRepository` - config path + env var
- `CopilotSettingsRepository` - env var + GitHub credentials

## Reference Implementation

See [references/antigravity-example.md](references/antigravity-example.md) for a complete working example showing:
- Full parsing test suite
- Probe behavior tests with mocking
- Probe implementation with process detection
- Provider class pattern

## Provider Icon

See [references/provider-icon-guide.md](references/provider-icon-guide.md) for creating provider icons:
- SVG template with rounded rectangle background
- PNG generation at 1x/2x/3x sizes
- Asset catalog setup
- ProviderVisualIdentity extension

## Checklist

- [ ] Parsing tests created and passing
- [ ] Probe behavior tests created and passing
- [ ] Probe implementation complete
- [ ] Provider class created
- [ ] Provider registered in ClaudeBarApp
- [ ] Visual identity added to Theme.swift (color, name, icons)
- [ ] Provider icon SVG created with rounded rect background
- [ ] Icon PNGs generated (64, 128, 192px)
- [ ] All 300+ existing tests still pass