add-provider — Claude Code skill: add a provider to ClaudeBar
Turns a new AI provider in ClaudeBar into a single JSON definition. Useful when a vendor has to be wired up without touching Swift.
npx skills add tddworks/ClaudeBar --skill add-provider -a claude-codeWhat add-provider does
A provider here is data. You drop one file at Modules/Providers/Resources/Providers/<id>.json, and the app picks it up on its own: ProviderCatalog.detect() reads every definition in the bundle plus anything in ~/.claudebar/providers and ~/.claudebar/extensions. Nothing registers it, and no Swift line names it.
Each definition answers three questions once: who the provider is (profile), how an account signs in (settings, accounts), and where usage comes from (dataSources). Every data source carries three closed sums — credential for the key's location, fetch for the request, mapping for reading the reply. One Provider class and one DataSource type run all of them. A <id>-*.js mapping script sits beside the JSON only when no rule can parse the format, a TUI screen being the obvious case.
Golden tests live in Modules/Providers/Tests/<Name>DefinitionTests.swift and come with every definition. When the language itself falls short — a new setting kind, case or worker — that counts as a design change: write it into TARGET_ARCHITECTURE and CANONICAL_MODEL, get the user's confirmation, then code. Workers take their names from a protocol, format or place (JSONRPCFetcher, OAuth2Refresher), never from a vendor.
What it helps with
- add a new AI provider to ClaudeBar with one JSON file
- keep an old saved key working after a settings change
- describe sign-in through a CLI's login file
- update a definition after the vendor changes its CLI, API or file format
- set a provider's name, symbol, colour and icon
Example prompts for the agent
- Add the Acme provider to ClaudeBar: the key sits in an environment variable and usage comes from its HTTP API
- The vendor changed its login file format — update the provider definition and its tests
- Create a definition for a new provider and write golden tests under
Modules/Providers/Tests/
How to install add-provider
1Skills CLI (Vercel)
npx skills add tddworks/ClaudeBar --skill add-provider -a claude-code2Manually: put the files in place
~/.claude/skills/add-provider/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/add-provider/
1Skills CLI (Vercel)
npx skills add tddworks/ClaudeBar --skill add-provider -a codex2Manually: put the files in place
.agents/skills/add-provider/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add tddworks/ClaudeBar --skill add-provider -a cursor2Manually: put the files in place
.cursor/skills/add-provider/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install tddworks/ClaudeBar add-provider2Manually: put the files in place
.github/skills/add-provider/SKILL.md1Skills CLI (Vercel)
npx skills add tddworks/ClaudeBar --skill add-provider -a gemini-cli2Platform CLI
gemini skills install https://github.com/tddworks/ClaudeBar --path .claude/skills/add-providerManually: put the files in place
.devin/skills/add-provider/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add tddworks/ClaudeBar --skill add-provider -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about add-provider
How do I install add-provider in Claude Code?
Run npx skills add tddworks/ClaudeBar --skill add-provider -a claude-code. The skill lands in ~/.claude/skills/add-provider/SKILL.md.
What does add-provider do?
It walks through adding an AI provider to ClaudeBar: the <id>.json file under Modules/Providers/Resources/Providers/, the three sums per data source, and golden tests for the definition.
Do I have to write Swift for a new vendor?
No. One Provider class and one DataSource type run every definition. Swift only enters the picture when the definition language cannot express what the provider needs, and that is a design change the user confirms first.
Source text
SKILL.md · Apache-2.0
---
name: add-provider
description: |
Add an AI provider to ClaudeBar as a JSON definition run by the one generic
Provider and DataSource. Use this skill when:
(1) Adding a new AI assistant provider (like Antigravity, Cursor, etc.)
(2) A provider needs a setting, an added account, or an old saved key kept working
(3) A provider's CLI, API or file format changed and its definition must follow
(4) User asks "how do I add a new provider" or "create a provider for X"
---
# 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](../../../docs/architecture/TARGET_ARCHITECTURE.md#10--a-definition-on-disk-is-a-provider)).
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](../../../docs/architecture/TARGET_ARCHITECTURE.md)
> (how a definition runs), [MODULAR_DESIGN.md](../../../docs/architecture/MODULAR_DESIGN.md)
> (which module a file goes in), and [CANONICAL_MODEL.md](../../../docs/architecture/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](../../../AGENTS.md#design-docs-are-the-source-of-truth)).
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](../../../docs/architecture/MODULAR_DESIGN.md#3--the-dependency-rules)):
- 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
```jsonc
{
"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 and added accounts
```jsonc
"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](../../../docs/features/providers/multi-account/design.md#what-an-accounts-sign-in-reaches)).
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`) |
```jsonc
"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
```jsonc
"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](../implement-feature/references/tdd-patterns.md#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.
```swift
@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:
```swift
@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](../../../docs/architecture/ENGINE_DESIGN.md#1--what-each-provider-needed-as-a-general-rule)
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](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
© tddworks · Apache-2.0 · tddworks/ClaudeBar