# Extension integrations

> Let the user connect a service with an API key, OAuth, a command or a custom flow, with several accounts, and sign providers in through it.

An integration is a service the user connects in Settings: Connect, Disconnect, and one row per account. The extension declares how to connect (its methods). The app runs the flow, keeps the secret in the auth store, and refreshes OAuth tokens. The extension reads a connection and resolves its credential when it needs one.

Use an integration for any account on another service: an API key, an OAuth sign-in, a token a CLI prints. For one secret with no accounts or checks, a [secret setting](https://zidane.si/docs/extensions-settings.md#string) is enough.

## Register an integration

From the kitchen-sink example (`examples/extension-kitchen-sink/integrations.ts`):

```ts
ctx.integrations.register({
  id: 'kitchen-cloud',
  name: 'Kitchen Cloud',
  description: 'A pretend service: any key starting with ks_ works.',
  icon: { gui: 'cloud', tui: 'K' },
  methods: [{ type: 'apiKey', id: 'key', label: 'API key', placeholder: 'ks_...', help: 'Find it in your Kitchen Cloud account.' }],
  verify: async credential => (credential.type === 'apiKey' && credential.key.startsWith('ks_') ? undefined : 'Keys start with ks_.'),
  account: async credential => (credential.type === 'apiKey' ? `chef-${credential.key.slice(3, 7)}` : undefined),
})

ctx.commands.register({
  id: 'cloud-whoami',
  title: 'Kitchen Cloud: who am I?',
  run: async () => {
    const connection = ctx.integrations.connection('kitchen-cloud')
    if (!connection) {
      await ctx.integrations.connect.start('kitchen-cloud', 'key').catch(() => undefined)
      return
    }
    const credential = await ctx.integrations.resolve(connection)
    await ctx.ui.toast({ text: `Connected as ${connection.account} (${credential.type})` })
  },
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique across extensions |
| `name` | `string` | Required. The name in Settings |
| `description` | `string` | One line under the name |
| `icon` | `{ gui?, tui? }` | An icon name for the desktop and web apps, a glyph for the terminal app |
| `methods` | `IntegrationMethod[]` | Required. The ways to connect ([methods](#methods)) |
| `beforeConnect(method)` | `string \| undefined` | Runs before any connect. Return a message to refuse: nothing is contacted ([gate](#gate-the-connect)) |
| `verify(credential)` | `string \| undefined` | Checks a credential before it is stored. Return a message to refuse it |
| `account(credential)` | `string \| undefined` | The account the credential belongs to (an email, a user name). Read once after connecting |
| `parseCommandOutput(stdout)` | `Credential` | Reads what a `command` method printed |
| `legacyCredentialKey` | `string` | Adopts a provider sign-in saved under its own key ([adopt](#adopt-a-saved-sign-in)) |

`register` returns a handle: `dispose()`, and `update(patch)` to change `name`, `description`, `icon`, `methods` or `beforeConnect` later. An invalid patch throws a `TypeError` and keeps the spec as it was.

## Methods

### apiKey

The user pastes a key. `{ type: 'apiKey', id, label, placeholder?, help? }`. The credential is `{ type: 'apiKey', key }`.

### oauth

A browser sign-in. The app runs the flow and refreshes the token.

| Field | Does |
| - | - |
| `flow` | `'pkce'` (a browser redirect) or `'device'` (a code the user enters on another page) |
| `authorizeUrl`, `tokenUrl` | The provider's endpoints |
| `clientId`, `clientSecret?` | The OAuth client. `clientSecret` is sent to the token endpoint when set |
| `scopes` | The scopes to ask for |
| `redirectUri` | For `pkce`: a loopback URL with an explicit port, such as `http://localhost:51121/callback` |
| `deviceUrl` | For `device`: the device authorization endpoint |
| `params` | Extra parameters for the authorize URL, such as `access_type` and `prompt` |

The credential is `{ type: 'oauth', accessToken, expiresAt?, scopes?, extra? }`. The app keeps the refresh token and never hands it out.

### command

The app runs a command and reads its output. By default, the trimmed output is the token: `{ type: 'token', token }`. `parseCommandOutput` reads other formats.

```ts
ctx.integrations.register({
  id: 'gh',
  name: 'GitHub',
  methods: [{ type: 'command', id: 'gh-cli', label: 'Use the gh CLI', command: ['gh', 'auth', 'token'], help: 'Runs gh auth token.' }],
})
```

A non-zero exit or empty output fails the connect, with the first line of the error output.

### custom

A flow the extension runs itself: a bespoke OAuth, an invite code, a sign-in that needs custom headers. `connect(io)` drives the app's sign-in UI and returns the credential.

```ts
import type { IntegrationCustomMethod } from 'zidane'

const invite: IntegrationCustomMethod = {
  type: 'custom',
  id: 'invite',
  label: 'Use an invite code',
  connect: async (io) => {
    const code = await io.prompt({ message: 'Your invite code' })
    if (!code)
      throw new Error('Cancelled.')
    io.log('Checking the code')
    const response = await fetch('https://example.com/api/redeem', { method: 'POST', body: JSON.stringify({ code }), signal: io.signal })
    const { token, email } = await response.json() as { token: string, email: string }
    return { credential: { type: 'token', token }, account: email }
  },
}

ctx.integrations.register({ id: 'example', name: 'Example', methods: [invite] })
```

`io` shows each step the way the app shows an OAuth flow, in whichever window started it:

| Member | Does |
| - | - |
| `openUrl(url)` | Shows a link to open |
| `showCode(code, url?)` | Shows a code to enter, and where |
| `prompt({ message, placeholder?, allowEmpty?, options? })` | Asks in a dialog. With `options`, a choice that resolves to the picked `id`. `undefined` when cancelled |
| `log(message)` | Shows a progress line |
| `signal` | Aborts when the user cancels or the extension unloads |

`connect` returns `{ credential, account?, label? }`. An `oauth` credential from a custom method may carry `refreshToken`: the store keeps it, and `refresh(credential)` gets it back when the token is about to expire. Without `refresh`, an expired custom credential asks the user to connect again.

`integrationMethodFromOAuthProvider(provider, { id?, label?, help? })` turns a `zidane/oauth` provider into a custom method. The [Bolt extension](https://github.com/Tahul/zidane/tree/main/extensions/bolt) signs in with its device flow this way:

```ts
import type { IntegrationSpec, OAuthProviderInterface } from 'zidane'
import { integrationMethodFromOAuthProvider } from 'zidane'

declare const boltOAuthProvider: OAuthProviderInterface

export const boltIntegration: IntegrationSpec = {
  id: 'bolt',
  name: 'Bolt',
  methods: [integrationMethodFromOAuthProvider(boltOAuthProvider, { help: 'Shows a code to approve on Bolt.' })],
  legacyCredentialKey: 'bolt',
}
```

## Credentials

`ctx.integrations.resolve(connection)` returns one of:

| Credential | From |
| - | - |
| `{ type: 'apiKey', key }` | An `apiKey` method, or `connect.key` |
| `{ type: 'oauth', accessToken, expiresAt?, scopes?, extra? }` | An `oauth` method, or a custom one. `resolve` refreshes the token when it is about to expire. `extra` holds provider fields kept with the grant, such as an account id |
| `{ type: 'token', token }` | A `command` method, or a custom one |

Call `resolve` each time you need the secret. Do not keep it: a refresh replaces it.

## Accounts

An integration holds several connections, one per account. The first becomes the default. Connecting an account that is already connected replaces it. Connections are shared: any extension reads any integration's connections.

| Member | Returns | Does |
| - | - | - |
| `connections(id)` | `Connection[]` | Every connection, the default first |
| `connection(id, { account? })` | `Connection \| undefined` | The default connection, or the one of `account` |
| `setDefault(id, account)` | `Promise<void>` | Makes that account the default. Rejects when there is none |
| `disconnect(id, { account? })` | `Promise<void>` | Removes one account, or every one |
| `onChange(id, listener)` | `Disposable` | Calls `listener(defaultConnection)` when any connection of the integration changes |
| `connect.key(id, key)` | `Promise<Connection>` | Connects with a key, as if the user pasted it |
| `connect.start(id, methodId, { signal? })` | `Promise<Connection>` | Starts an `oauth`, `command` or `custom` flow. The app shows its UI. Resolves when it completes |

A `Connection` is `{ integrationId, methodId, account, label?, isDefault?, connectedAt }`. `account` is `'default'` when the integration names none.

## Gate the connect

`beforeConnect` runs before any connect, with the method the user picked. Return a message to refuse: the user sees it, and nothing is contacted. The [Antigravity extension](https://github.com/Tahul/zidane/tree/main/extensions/antigravity) refuses until the user accepted a risk and set an OAuth client, and updates its method when the client changes:

```ts
import type { ExtensionContext, IntegrationMethod, SettingsSchema } from 'zidane'

const settings = {
  items: {
    acceptRisk: { type: 'boolean', label: 'I accept the risk', default: false },
    clientId: { type: 'string', label: 'OAuth client ID', default: '' },
  },
} as const satisfies SettingsSchema

const method = (clientId: string): IntegrationMethod => ({
  type: 'oauth',
  id: 'google',
  label: 'Sign in with Google',
  flow: 'pkce',
  authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenUrl: 'https://oauth2.googleapis.com/token',
  clientId,
  redirectUri: 'http://localhost:51121/oauth-callback',
  params: { access_type: 'offline', prompt: 'consent' },
})

export function setupSignIn(ctx: ExtensionContext<typeof settings>): void {
  const integration = ctx.integrations.register({
    id: 'antigravity-demo',
    name: 'Antigravity',
    methods: [method(ctx.settings.get('clientId') ?? '')],
    beforeConnect: () => (ctx.settings.get('acceptRisk') !== true ? 'Accept the risk in Settings first.' : !ctx.settings.get('clientId') ? 'Set an OAuth client first.' : undefined),
  })
  ctx.settings.onChange('clientId', id => integration.update({ methods: [method(id ?? '')] }))
}
```

## Providers that sign in

A provider descriptor with `integration` signs in through it ([providers](https://zidane.si/docs/extensions-catalogs.md#providers)):

- Pickers show the provider signed in while the integration has a connection, one entry per account, the default active.
- "Sign in" starts the integration's `oauth` or `custom` method, and asks which when there are several.
- Usage probes run with each connection.

The provider's factory resolves its token through `ctx.integrations`. With `accounts: true`, a session can pin one account, and the factory gets it as `options.account`:

```ts
import type { ProviderDescriptor, ProviderFactoryOptions } from 'zidane'

declare function myProvider(options?: ProviderFactoryOptions): ReturnType<ProviderDescriptor['factory']>

ctx.providers.register({
  key: 'my-gateway',
  label: 'My gateway',
  factory: options => myProvider(options),
  envKey: undefined,
  integration: 'my-gateway',
  accounts: true,
})

async function tokenFor(account?: string): Promise<string | undefined> {
  const connection = ctx.integrations.connection('my-gateway', account ? { account } : undefined)
  if (!connection)
    return undefined
  const credential = await ctx.integrations.resolve(connection)
  return credential.type === 'oauth' ? credential.accessToken : undefined
}
```

## Adopt a saved sign-in

A provider may keep its sign-in in `credentials.json` under its own key. `legacyCredentialKey` names that key. When the integration registers and has no connection, the entry under that key becomes one: tokens and extra fields kept, the account from the entry's `account` or `email`, else `'default'`. The first `oauth` or `custom` method refreshes it.

The old entry stays, so going back to an older zidane still works. It is marked, so a later Disconnect sticks.

## Refreshes

An OAuth refresh fires the `oauth:refresh` hook to every extension that observes it with `ctx.hooks.on('oauth:refresh', …)`, after the new tokens are stored.

## In each app

| App | Settings | Notes |
| - | - | - |
| Terminal app | Settings has an Integrations tab: a row per account with a default marker. `Enter` connects, adds an account or disconnects one. `D` makes an account the default | Offers every method, with a pick when there are several |
| Desktop app | Settings › Extensions lists each account with Default, Make default, Disconnect and Add account | Sign-in questions show as dialogs |
| Web app | The same list in the extension's settings | The flow runs in the browser that started it, even on another machine than the server |

Secrets live in the auth store of the process that runs the extension: the daemon's or the server's in those modes.

## Pitfalls

- A `pkce` method needs a loopback `redirectUri` with a port, and the OAuth client must allow it.
- A custom flow that runs its own local callback server cannot finish from a remote window: the callback reaches the server's machine. Use `showCode` or `prompt` for remote use.
- A custom `connect` is not bounded by the callback timeout, since sign-ins can take minutes. `beforeConnect`, `verify`, `account` and `parseCommandOutput` are.
- Usage probes do not refresh an expired integration token. The account shows as unauthorized until the provider's next run refreshes it.
- An integration's `id` is shared by every extension. Pick one that names your service, not a generic word.

## Related

- [Settings](https://zidane.si/docs/extensions-settings.md): secret settings and the values `beforeConnect` reads.
- [Catalogs](https://zidane.si/docs/extensions-catalogs.md#providers): provider descriptors.
- [Providers](https://zidane.si/docs/providers.md): how sessions pick a provider and an account.
