Guide for adding new AI providers to ClaudeBar using TDD patterns...
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 inModules/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.
| 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):
JSONRPCFetcher, OAuth2Refresher), never a vendor.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.{
"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, Linux and Windows) · 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, on every platform; a test hands the stubbed make its running: programs) · 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": [
{ "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" }
]
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.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:
{{account.<id>}}
(a folder setting, {{account.folder}}) or a { "setting": … } lookup.accounts.patch; forgetting a read can't leak.| 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}}").
"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.
Name each test should <outcome> [when <situation>], in the person's words, never a method, type or mechanism verb → Naming tests.
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.
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
}
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.
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).
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:).
docs/providers/<id>/README.md (what users see, setup, errors) and design.md (sources, fields, gotchas).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.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.## [Unreleased] in CHANGELOG.md.python3 scripts/gen-docs.py && python3 scripts/check-docs.py --strict.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:
JSONSettingsRepository.legacySettingKeysJSONSettingsRepository.legacyDefaultsKeys (a saved number or list reads as text)ProviderVault.legacyKeyscodexHome → folder) → JSONSettingsRepository.renamedAccountValuesEach moves to its new place the first time it's saved, and each needs a test.
design.md<id>.json makes them pass; no vendor-named Swift anywhereDataSources, test-first, and listed in ENGINE_DESIGN §1{{account.x}}, and a test checks dataSources(forAccount:)"order" set if it has a place in the default lineup, and its id in DetectionTestsprofile.look filled in and the icon added to the asset catalogREADME.md Providers table, its landing-page tile and counts (npm test in website/), and the CHANGELOG line written; docs check passestuist test green