add-provider

Guide for adding new AI providers to ClaudeBar using TDD patterns. Use this skill when:(1) Adding a new AI assistant provider (like Antigravity, Cursor, etc.)(2) Creating a usage probe for a CLI tool

add
provider

SKILL.md

GitHub

Add a Provider to ClaudeBar

A provider is data: one file, Modules/Providers/Resources/Providers/<id>.json. The app finds it — ProviderCatalog.detect() reads every definition in the bundle (and in ~/.claudebar/providers, ~/.claudebar/extensions) and makes each on one shared Engine (TARGET_ARCHITECTURE §10). There is no registration step and no Swift line that names it. It is an abstraction layer that answers three questions, each once: who the provider is (profile), how an account is signed in (settings, and accounts when the vendor keeps a login in a folder), and how to get its usage (dataSources: where the key is, how to fetch, how to read the answer). One Provider class and one DataSource type run every definition. You write no Swift for a vendor: no XxxProvider, no XxxUsageProbe, no XxxCredentialLoader.

Read first: TARGET_ARCHITECTURE.md (how a definition runs), MODULAR_DESIGN.md (which module a file goes in), and CANONICAL_MODEL.md (the words). Every built-in provider is a definition, so there is a worked example for most shapes: an API key with a region (minimax.json), a CLI's login file renewed by OAuth (grok.json) or by the CLI itself (gemini.json), a TUI (kimi.json), a browser session over several requests (alibaba.json), an app's local server (antigravity.json), cloud metrics priced into money (bedrock.json). Their tests are in Modules/Providers/Tests/.

The docs are the source of truth (AGENTS.md). A provider that fits the definition language needs no design change. One that needs the language to grow — a new case and worker, a new capability (CANONICAL §2.1), a new setting kind — is a design change: write it into TARGET_ARCHITECTURE / CANONICAL_MODEL first and ask the user to confirm before writing code.

The pieces

Piece Where You touch it when
<id>.json Modules/Providers/Resources/Providers/ always
<id>-*.js mapping script beside the JSON only when no mapping rule can read the format (a TUI screen)
golden tests Modules/Providers/Tests/<Name>DefinitionTests.swift always
a generic rule or worker Modules/DataSources/ (+ DataSourcesTests) when the definition language can't say what the provider needs
look (name, symbol, colour, icon) profile.look in <id>.json; the icon image in the asset catalog always
research docs/providers/<id>/README.md (users), design.md (contributors) always

The rules (MODULAR_DESIGN §3–4):

  • No vendor's name in a module's Swift. A worker is named for its protocol, format or place (JSONRPCFetcher, OAuth2Refresher), never a vendor.
  • No Probe names. The words are DataSource, Fetch, Mapping, Usage, Quota, Plan and Cost.
  • Modules/* never import Domain. The usage model is Quotas; providers, settings and accounts are Providers.

The definition: three closed sums per data source

{
  "profile": {                          // WHO IT IS
    "id": "acme",                       // stable forever: settings and the menu bar key on it
    "name": "Acme",
    "links": { "dashboard": "https://…", "status": "https://…" },
    "look": { "symbol": "bolt.fill", "icon": "AcmeIcon",          // SF Symbol, asset name
              "color": { "light": [0.2, 0.5, 0.9], "dark": [0.3, 0.6, 1.0] },
              "gradientEnd": { "light": [0.1, 0.3, 0.7], "dark": [0.2, 0.4, 0.8] } }
  },
  "cli": "acme",                        // optional
  "enabledByDefault": true,
  "order": 280,                         // its default place in the lineup; none → after the rest, by name
  "guestPasses": {},                    // optional: a capability the engine runs (Claude's)
  "defaultDataSource": "api",
  "dataSources": [
    {
      "kind": "api",                    // what Settings' Data source picker saves as <id>.probeMode
      "label": "API", "summary": "Calls Acme API directly",
      "credential": { … },              // CredentialLookup — where the key is
      "fetch":      { … },              // Fetch — how to ask
      "mapping":    { … },              // Mapping — how to read the answer
      "fallback": "cli"                 // optional: try this kind when this one fails
    }
  ]
}
Sum Cases
credential environment ("loginShell": true also asks the person's login shell) · browserStorage (a site's local storage, one browser profile) · setting (a key pasted into ClaudeBar, in the Keychain) · jsonFile (paths $.a.b, or a list — the first that answers; ~ and ${VAR:-default}; record, defaults) · keychain (account, encoding: "goKeyringBase64") · browserCookies (format: "value"|"header") · sqlite · firstOf — any of them refined with match (a value must fit a pattern, or no key), with (values added) and cookies (named cookies read out of a Cookie header); plus "refresh": { "oauth2": … } or "refresh": { "cli": … } (the CLI renews its own file)
fetch http ({{token}}, {{x#host}}, {{x#jwt.claim}}, {{system.timeZone}}) or "http": { "steps": [ … ] } (keep, optional, unless, attempts, dropEmpty) · jsonRpc · cli — a terminal, for a TUI (input, inputDelay, autoResponses, readyWhen, screen: "rendered"; Mac only until the Terminal port) · command — pipes, exit code reported (Mac and Windows, like jsonRpc and script) · file · directory · sqlite (rows of an app's own database, read-only) · localServer (an app's server on 127.0.0.1, found through its process) · cloudWatch (cloud metrics through a port, priced from a PriceCatalog)
mapping json (below) · text (error phrases, then label + regex for % left/used) · script (a .js file in QuickJS, no I/O, no Intl; host humanDate(), jsonDecimal(), decimalCents(), decimalAdd(), decimalMultiply(); Date's local time is the person's zone on every platform; context.values from "values"; returns quotas (with group), notes, plan, cost (with lines), account, or error)

Per data source, also: errors (http.<status>, http.default, cli.missing, cli.nonzero, cli.failed → a reason; a 429 is always a rate limit), fallbackOn (hand-off by failure tag), fallback, cache.ttl (also the background-refresh floor), context (other JSON files the mapping may read), recover.patchJSONFile, requiresFiles, verifyBeforeBackground. Per provider: links.dashboardByPlan and accounts (below). Who is signed in is never written per data source: it is accounts.folder.signedInAs, said once.

Settings and added accounts

"settings": [
  { "id": "apiKey", "label": "API Key", "kind": "secret", "scope": "account", "for": ["api"] },
  { "id": "region", "label": "Region", "scope": "account", "default": "china",
    "kind": { "choice": [ { "id": "china", "label": "China", "host": "api.acme.cn" },
                          { "id": "intl",  "label": "International", "host": "api.acme.com" } ] } },
  { "id": "home", "label": "Signed-in Folder", "scope": "account", "for": ["cli"], "kind": { "path": { "mustExist": true } } },
  { "id": "authEnvVar", "label": "Environment variable", "default": "ACME_API_KEY" }
]
  • A kind owns its rule: secret (to the Keychain, fills nothing), choice (its options carry values), path (mustExist), text (pattern).
  • {{setting.region.host}} fills any string of the definition; a blank setting leaves its template unfilled (a script's values drop it).
  • scope: "account" is what Add Account asks for; "for": [kind] asks only while that data source is active, and a login without such a value runs only the sources that don't need it.

Added accounts: what an account's sign-in reaches

An account shows only what its own sign-in reaches (multi-account design). The default account is signed in on this Mac. An added account is signed in through what the person gave it: a key, a folder, or both. Write every read once, for any account:

  • A read that names the account is each account's own: {{account.<id>}} (a folder setting, {{account.folder}}) or a { "setting": … } lookup.
  • Any other read (an environment variable, a fixed path or Keychain name, browser cookies, an app's own database, a log at a fixed place) is this Mac's: the default account makes it, and an added account leaves it out by itself. There is no accounts.patch; forgetting a read can't leak.
  • A folder an account was given, and a read uses, must still be there, or that account is not signed in.
The vendor keeps a login… You write
as a key the person can paste (API key, session token, cookie) an account-scope secret setting, and { "setting": "<id>" } somewhere in the lookup chain (append it; keep the default account's order). No accounts block
in a folder the person names on the form an account-scope path setting with the vendor's default folder ("default": "~/.grok"), reads under {{account.<id>}}; accounts.cli only if a CLI must be pointed at it
in a folder ClaudeBar checks (Choose Signed-in Folder, Sign in with browser) accounts.folder and accounts.signIn, reads under {{account.folder}}, and accounts.cli
as a key the CLI takes two data sources: one with the account's key handed to the CLI ({{token}} in its environment), falling back on authenticationRequired to one that runs the CLI as its own login, which is this Mac's (ampcode.json)
"accounts": {
  "folder": {
    "default": "${CODEX_HOME:-~/.codex}",           // where the vendor keeps the default account's login
    "signedInAs": {                                 // "Signed in as …": read once, for every data source
      "file": "{{account.folder}}/auth.json",       // a list where the vendor keeps the default elsewhere
      "id": "$.tokens.account_id",                  // checked before and after every fetch of an added account
      "email": "$.tokens.id_token#jwt.email"        // the pill's; a mapping reads any field as $signedInAs.<field>
    },
    "notSignedIn": "…"                              // optional
  },
  "signIn": { "args": ["login"] },                  // the vendor's login; its program is "cli"
  "cli": {                                          // every CLI run of an added account: fetch, renewal, sign-in
    "environment": { "set": { "CODEX_HOME": "{{account.folder}}" }, "unset": ["OPENAI_API_KEY"] },
    "args": ["-c", "cli_auth_credentials_store=\"file\""]
  }
}

Where the vendor keeps the default account somewhere else, write both places where they are read: a path list (["{{account.folder}}/.claude.json", "${CLAUDE_CONFIG_DIR:-~}/.claude.json"]) or a name worked out from the folder ("Claude Code-credentials-{{account.folder#sha256.8}}").

The JSON mapping

"json": {
  "plan":  { "path": "$credential.plan", "plans": { "max": "claudeMax" } },   // or "badges"
  "email": ["$.account.email", "$credential.email"],
  "quotas": [
    { "kind": "session", "at": "$.five_hour", "usedPercent": "utilization",
      "resetsAt": { "iso8601": "resets_at" } },               // or epochSeconds / secondsFromNow
    { "kind": "model", "each": "$.limits", "where": { "path": "kind", "equals": "weekly" },
      "name": { "firstOf": ["model.name"], "firstWord": true, "lowercase": true },
      "usedPercent": "percent", "unique": true, "overLimit": true, "countdown": "hours",
      "window": [{ "seconds": "window_seconds" }, { "days": 7 }] },   // the response's word, else the provider's
    { "kind": "time", "name": "Credits",                             // money, not a percentage:
      "left": { "money": "$.data.remaining", "of": "$.data.limit", "currency": "USD" } }  // no "of" = a balance
  ],
  "cost": [                                                    // the first shape that answers
    { "kind": "extraUsage", "when": { "path": "$.spend.enabled", "equals": true },
      "used":  { "amount": "$.spend.used.amount_minor", "decimals": "$.spend.used.exponent" },
      "limit": { "amount": "$.spend.limit.amount_minor", "decimals": "$.spend.limit.exponent" } }
  ],
  "whenEmpty": { "if": { "path": "$.plan", "equals": "free" }, "quotas": [ … ], "otherwise": "No data yet" },
  "notAnObject": "Failed to parse usage response"
}

Paths: $.a.b from the root, a.b from the current object, $header.x, $key (the map key inside each), $credential.x (a non-secret credential value), $signedInAs.field (who is signed in: a field of accounts.folder.signedInAs), $context.file.field (a field of another context file the data source reads). A list of values means the first that answers; a number is a constant.

TDD workflow (Chicago school)

Name each test should <outcome> [when <situation>], in the person's words, never a method, type or mechanism verb → Naming tests.

1 · Research and fixtures

Find where the usage really comes from (CLI command, endpoint, local file) and capture real responses, redacted, including the failure answers: logged out, rate limited, a free plan, an empty account. Write what you learned in docs/providers/<id>/design.md.

2 · Golden tests first (red)

Modules/Providers/Tests/<Name>DefinitionTests.swift runs the real definition through the real Provider over stubbed connections with StubbedProvider (Tests/Support/Connections.swift). They fail first because <id>.json doesn't exist.

@MainActor
@Suite
struct AcmeDefinitionTests {
    @Test
    func `should read Acme from its API by default`() throws {
        let acme = try ProviderFactory.builtIn("acme")
        #expect(acme.dataSources.map(\.kind) == ["api"])
        #expect(acme.defaultDataSource == "api")
    }

    @Test
    func `should show the session window when the API answers`() async throws {
        let stub = try StubbedProvider(providerId: "acme")
        defer { stub.cleanUp() }
        stub.environment = ["ACME_API_KEY": "test-key"]
        stub.answerHTTP(#"{"session":{"used_percent":30,"reset_at":1735000000}}"#)

        let usage = try await stub.make("acme").refreshPlain()

        #expect(usage.quota(for: .session)?.percentRemaining == 70)
        #expect(usage.quota(for: .session)?.resetsAt == Date(timeIntervalSince1970: 1735000000))
    }

    @Test
    func `should say the key is missing when the API has no key`() async throws {
        let stub = try StubbedProvider(providerId: "acme")
        defer { stub.cleanUp() }
        let acme = try stub.make("acme")

        await #expect(throws: UsageError.authenticationRequired) { try await acme.refresh(acme.defaultAccount) }
        #expect(acme.defaultAccount.lastFailedStep == .lookup)
    }
}

Assert on state: the usage, lastError, lastFailedStep, answeredBy, and files written back. Don't verify() calls. Cover every fixture from step 1.

When a provider can have an added account, test that it reaches only its own sign-in. definition.dataSources(forAccount:) is what an added account runs; a test reading a built-in data source for the default account uses definition.asDefaultAccount(kind) (Tests/Support/DefaultAccount.swift), since {{account.x}} is only filled for an account:

@Test
func `should never read this Mac's key for an added account`() throws {
    let acme = try ProviderFactory.builtIn("acme")

    let added = try #require(try acme.dataSources(forAccount: [:]).first)

    #expect(added.credential == .firstOf([.setting("apiKey")]))   // ACME_API_KEY is this Mac's
}

3 · Write the definition (green)

Add <id>.json until the golden tests pass. ProviderFactory.builtIn validates it: kinds are unique, and the default and every fallback name an existing kind.

4 · When the language can't say it

Don't write vendor code. Find the generic shape of the need, for example "a list filtered by a field" or "money in minor units", and add it to DataSources test-first in Modules/DataSources/Tests/. Then use it from the JSON. ENGINE_DESIGN §1 lists the pieces each provider needed. Add your row there.

Only a format no rule can read, like a terminal UI screen, gets a mapping script, <id>-<what>.js. Test it through Swift with real captured screens (see ClaudeUsageScreenTests).

5 · Give it a look and a place

There is nothing to register: the file being in Resources/Providers/ makes it a provider. "order" places it in the default lineup (the built-ins use 10, 20, … 270); leave it out to come after the rest, by name. The person's own order still wins. DetectionTests lists the built-ins in order — add your id there.

Every provider gets the same Engine: settings, the vault, the login shell (for a lookup with "loginShell": true), the cloud ports (for a cloudWatch fetch), the guest-passes runner (for "guestPasses": {}). Ask for one in the JSON; never pass one in Swift.

Its name, symbol and colours are profile.look in the JSON — no switch id table to edit. Add the icon image to the asset catalog under look.icon (references/provider-icon-guide.md). Settings need no new protocol: the data source choice is dataSourceKind(forProvider:), and an on/off setting a definition names (for example fallback.enabledBySetting) is isOn(_:forProvider:).

6 · Docs and release note

  • docs/providers/<id>/README.md (what users see, setup, errors) and design.md (sources, fields, gotchas).
  • A row in the Providers table of the root README.md — name, what it tracks, a link to its docs/providers/<id>/README.md — placed by its "order", so the table follows the default lineup.
  • A tile in the landing page's providers grid (website/public/index.html, <div class="pv" data-id="<id>" …>), in lineup order, and every count on the page raised by one — npm test in website/ reads the definitions and fails until both match.
  • One line under ## [Unreleased] in CHANGELOG.md.
  • python3 scripts/gen-docs.py && python3 scripts/check-docs.py --strict.

Keeping what people saved

Every built-in provider is already a definition; when you change one, keep every saved value working. The data source choice is <id>.probeMode, and a setting foo is read from <id>.foo. A value kept somewhere else gets a row, never a branch:

  • a setting under another key → JSONSettingsRepository.legacySettingKeys
  • a setting in UserDefaults → JSONSettingsRepository.legacyDefaultsKeys (a saved number or list reads as text)
  • a secret in UserDefaults or an older Keychain item → ProviderVault.legacyKeys
  • an added account's value under an older name (codexHome → folder) → JSONSettingsRepository.renamedAccountValues

Each moves to its new place the first time it's saved, and each needs a test.

Checklist

  • Real fixtures captured (success and every failure), research in design.md
  • Golden tests written first and failing
  • <id>.json makes them pass; no vendor-named Swift anywhere
  • Any new mapping/fetch/lookup ability added generically to DataSources, test-first, and listed in ENGINE_DESIGN §1
  • An added account reaches only its own sign-in: its key is an account-scope setting in the lookup chain, folder reads name {{account.x}}, and a test checks dataSources(forAccount:)
  • "order" set if it has a place in the default lineup, and its id in DetectionTests
  • profile.look filled in and the icon added to the asset catalog
  • Provider docs, its row in the root README.md Providers table, its landing-page tile and counts (npm test in website/), and the CHANGELOG line written; docs check passes
  • tuist test green