@justsilver/opencode-providers 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-10-07
11
+
12
+ ### Fixed
13
+
14
+ - **`/connect-providers` 强制刷新后列表不更新**:`doRefresh` 刷新成功只调了
15
+ `ctx.data.location.integration.invalidate()`,而 `invalidate` 仅删除 sync 标记、**不会重新拉取**,`list()`
16
+ 读到的仍是旧的客户端缓存(此时服务端其实已注册新供应商)→ 重开的弹窗还是旧列表。
17
+ 改用与其它操作一致的 `reloadIntegrations()`(`invalidate` + `await sync`),刷新后重开的列表与服务端
18
+ `/api/integration` 一致。证据:`packages/client/src/solid/data.ts` 的 `locationResource`
19
+ (`list` 读 store、`sync` 才 `sync.run` 拉取、`invalidate` 只 `sync.invalidate`)
20
+ - **`/connect-providers` 刷新进度模态看不到**:`refreshWithProgress` 用 `void` 打开 `dialog.alert` 后
21
+ 在 `finally` 里立即 `clear()`,刷新一返回就把模态抹掉(快则一帧都看不到)。改为刷新期间不再自行 `clear`:
22
+ 列表路径由重开的列表弹窗顶替模态,空态路径(无后续弹窗)显式 `clear`,模态在整个刷新期间保持可见
23
+
24
+ ## [0.3.0] - 2026-10-07
25
+
26
+ ### Added
27
+
28
+ - `/connect-providers` 账号管理支持**重命名**(`ctrl+r`,弹窗预填当前名称)与**删除**(`ctrl+d`,二次确认后删除);
29
+ 删除最后一个账号时提示 `Disconnected <name>` 并关闭弹窗(该供应商随之从 `/models` 消失)。
30
+ 动作触发后**留在弹窗**并原地刷新(与内置 `/connect` 一致);选中已激活账号不再重复调用 activate
31
+ - 强制刷新改为**阻塞式进度模态**(`dialog.alert`,刷新结束自动 `clear`):刷新期间输入被模态捕获、无法重复触发;
32
+ `forceRefresh` 增加**单飞**(并发调用共用同一次刷新)
33
+
34
+ ### Changed
35
+
36
+ - **移除安装脚本**:不再提供 `install.sh` / `install.ps1` / `uninstall.sh` / `uninstall.ps1`,本插件**只通过 npm 包分发**;
37
+ 本地开发改为在 `plugins` 里指向工作树的**绝对路径目录**(opencode 按「本地目录插件」加载,改完自动热重载)
38
+
10
39
  ## [0.2.1] - 2026-10-07
11
40
 
12
41
  ### Fixed
@@ -121,7 +150,9 @@
121
150
  - 插件源码从 `.opencode/plugins/opencode-providers/` 移到 `plugin/opencode-providers/`:仓库自身不再是插件发现根,避免在仓库里跑 opencode 时与全局安装的同 id 副本相撞(`Duplicate plugin ID` → 面板里一条 `failed`)
122
151
  - 安装后**通常无需重启**(插件目录被文件监视热重载),不再要求 `opencode service restart`
123
152
 
124
- [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.1.0...HEAD
153
+ [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.3.1...HEAD
154
+ [0.3.1]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.3.1
155
+ [0.3.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.3.0
125
156
  [0.1.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0
126
157
  [0.1.0-beta.2]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.2
127
158
  [0.1.0-beta.1]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.1
package/README.md CHANGED
@@ -7,14 +7,15 @@
7
7
  registry/index.json ← manifest:schemaVersion / revision / providers[](CLI 自动维护)
8
8
  registry/providers/<id>/ ← 每家:provider.json(供应商参数)+ models.json(该家模型)
9
9
  registry/models/<lab>/<model>.json ← 顶层共享模型(多家用 base 引用)
10
- plugin/opencode-providers/ ← 插件源码(安装时整目录复制到 ~/.config/opencode/plugins/)
10
+ plugin/opencode-providers/ ← 插件源码(npm 包的 server / tui 入口所在)
11
11
  ├── index.ts ← server 入口:拉注册表 → 注册 integration/provider/models
12
12
  ├── tui.ts ← TUI 入口:注册 /connect-providers 命令(不写 JSX,见下)
13
13
  ├── registry/ ← 纯逻辑:schema 校验 / 元数据映射 / 拉取缓存
14
14
  └── view/ ← /connect-providers 交互
15
15
  ```
16
16
 
17
- > 源码**故意不放在 `.opencode/plugins/` 下**:那样在本仓库里跑 opencode 时,仓库副本会和全局安装副本撞同一个插件 id,
17
+ > 源码**故意不放在 `.opencode/plugins/` 下**:opencode 会自动发现该目录下的插件,
18
+ > 那样在本仓库里跑 opencode 时就会和 npm/全局那份撞同一个插件 id,
18
19
  > 被 supervisor 判为 `Duplicate plugin ID: opencode-providers`,在 `/plugins` 面板里显示成一条 `failed`(实际加载的是另一条,功能正常)。
19
20
 
20
21
  ## 为什么需要它
@@ -25,8 +26,7 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
25
26
 
26
27
  ## 安装
27
28
 
28
- ### 方式 A:npm 包(推荐)
29
-
29
+ 本插件**只通过 npm 包分发**(走 opencode 的 `plugins` 配置或 `opencode plugin add`),没有安装脚本。
30
30
  在 `opencode.json(c)` 里写一行(不需要手写 provider/模型):
31
31
 
32
32
  ```jsonc
@@ -58,35 +58,24 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
58
58
  }
59
59
  ```
60
60
 
61
- ### 方式 B:安装脚本(零依赖备用路径)
61
+ 装完**通常无需重启**(插件被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
62
+ 卸载:`opencode plugin remove @justsilver/opencode-providers`(或把 `plugins` 里那一行删掉)。
62
63
 
63
- ```bash
64
- # Linux / macOS
65
- ./install.sh
66
- ```
64
+ ### 本地开发:直接跑工作树
67
65
 
68
- ```powershell
69
- # Windows
70
- .\install.ps1
71
- ```
66
+ 不想先发版也能让 opencode 加载**当前工作树**——`plugins` 里给一个**绝对路径目录**即可
67
+ (opencode 对绝对路径按「本地目录插件」处理;该目录需含 `index.ts` 与 `tui.ts`):
72
68
 
73
- 脚本把 `plugin/opencode-providers/` 整目录装到:
74
-
75
- ```
76
- ~/.config/opencode/plugins/opencode-providers/ # $XDG_CONFIG_HOME 优先
69
+ ```jsonc
70
+ {
71
+ "plugins": [
72
+ { "package": "E:/Code/Projects/Agent/opencode-providers/plugin/opencode-providers" }
73
+ ]
74
+ }
77
75
  ```
78
76
 
79
- 装完**通常无需重启**(插件目录被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
80
- 卸载:`./uninstall.sh` / `.\uninstall.ps1`。
81
-
82
- 开发时把**工作树**(含未提交改动)直接部署到全局插件目录:
83
-
84
- ```bash
85
- bash install.sh --local
86
- ```
87
- ```powershell
88
- pwsh -NoProfile -File .\install.ps1 -Local
89
- ```
77
+ 这样改完源码会被文件监视热重载,无需发布、也无需 `npm install`。
78
+ (它和 npm 包**同 id**,别同时留着两条,否则 `/plugins` 面板会出现一条 `failed`。)
90
79
 
91
80
  ## 使用
92
81
 
@@ -187,7 +176,7 @@ registry/
187
176
  - **不做参数推断**:注册表写什么就是什么,插件不会去猜 `limit`/`cost`。
188
177
  - **不调供应商 API**:不访问 `/v1/models`,也就没有运行期的额外网络与失败面。
189
178
  - **不声明 `env` 认证**:本插件只走 `/connect`-式交互;opencode 的 env 是静默旁路且不在 `/connect` 里展示。
190
- - 账号重命名/删除请用内置 `/connect`(同一个 integration,同一个凭据表)。
179
+ - 不改注册表来源时**不需要** `opencode.json` 里的 `providers` 声明(那是另一条路线)。
191
180
 
192
181
  ## 开发
193
182
 
@@ -231,9 +220,9 @@ push tag `vX.Y.Z-beta.n`(或 Actions → Release → Run workflow 勾 `publish
231
220
 
232
221
  | 现象 | 原因 / 处理 |
233
222
  |---|---|
234
- | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:脚本装到 `~/.config/opencode/plugins/` + 又在 `opencode.json` 里配了 npm 包,或某个项目的 `.opencode/plugins/` 里也有一份)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
223
+ | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:`opencode.json` 里配了 npm 包,某个项目的 `.opencode/plugins/` 里又放了一份;或本地绝对路径与 npm 包同时留着)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
235
224
  | `/connect-providers` 说没有可用供应商 | 注册表没加载成功。看 server 日志里的 `[opencode-providers]`,并确认 `/api/plugin` 里自己那条 `state.status` 是 `active` |
236
225
  | `/models` 里看不到新供应商 | 正常:`activation: "auto"`,在 `/connect-providers` 里存过 key 之后才会出现 |
237
- | 改了插件代码没生效 | 覆盖的是**全局**那份(`~/.config/opencode/plugins/opencode-providers/`)。开发时用 `install.ps1 -Local` / `install.sh --local` 把工作树复制过去 |
226
+ | 改了插件代码没生效 | 改的是**工作树**,但 opencode 加载的是 npm 缓存里那份(`~/.cache/opencode/npm/@justsilver/opencode-providers@…/`)。开发时改用上面的「本地开发:直接跑工作树」把 `plugins` 指到工作树目录 |
238
227
 
239
228
  验证命令见上面的「开发」小节。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justsilver/opencode-providers",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Register providers missing from the models.dev catalog in OpenCode V2 from a self-hosted registry — no opencode.json needed",
6
6
  "license": "MIT",
@@ -2,13 +2,16 @@
2
2
  * `/connect-providers` flow.
3
3
  *
4
4
  * Mirrors the built-in `/connect` interaction for the providers this plugin
5
- * registers: pick a provider, then either add an account (paste an API key) or
6
- * activate one that already exists. Everything goes through the normal server
7
- * API, so credentials land in opencode's own store.
5
+ * registers: pick a provider, then either add an account (paste an API key),
6
+ * activate one that already exists, rename it, or delete it. Everything goes
7
+ * through the normal server API, so credentials land in opencode's own store.
8
8
  *
9
- * Out of scope on purpose (use the built-in `/connect` for these): renaming and
10
- * deleting accounts. Renaming needs a bound footer action; deletion needs a
11
- * confirm binding. Both are cosmetic actions on the same records.
9
+ * Why this looks a bit different from the built-in dialog: the plugin API
10
+ * (`ctx.ui.dialog.select`) is a one-shot, non-reactive wrapper. Options are
11
+ * frozen when the prompt opens and actions cannot be hidden or disabled, so we
12
+ * cannot mutate a row in place the way the core `DialogIntegration` does. This
13
+ * flow instead re-opens the account manager after every mutation to show fresh
14
+ * state, and uses `dialog.confirm` for the delete confirmation.
12
15
  */
13
16
 
14
17
  import type { IntegrationInfo } from "@opencode/client"
@@ -18,52 +21,87 @@ import { registryRpc } from "../rpc.ts"
18
21
 
19
22
  type Context = Plugin.Context
20
23
  type Connection = IntegrationInfo["connections"][number]
24
+ type CredentialConnection = Extract<Connection, { type: "credential" }>
21
25
 
22
26
  const INTEGRATION_SOURCE = "opencode-providers"
23
27
  const ADD_ACCOUNT = "\u0000add-account"
24
28
 
25
29
  export async function connectProviders(ctx: Context): Promise<void> {
26
- const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
30
+ while (true) {
31
+ const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
27
32
 
28
- if (integrations.length === 0) {
29
- // Nothing to connect yet — offer the refresh here rather than making the user
30
- // restart the service and wait out the 6h TTL.
31
- const confirmed = await ctx.ui.dialog.confirm({
33
+ if (integrations.length === 0) {
34
+ // Nothing to connect yet — offer the refresh here rather than making the user
35
+ // restart the service and wait out the 6h TTL.
36
+ const confirmed = await ctx.ui.dialog.confirm({
37
+ title: "Connect providers",
38
+ message:
39
+ "No providers from the registry are available. Force a refresh to re-fetch the registry, then run /connect-providers again.",
40
+ label: { confirm: "Force refresh" },
41
+ })
42
+ if (confirmed) {
43
+ const ok = await refreshWithProgress(ctx)
44
+ // No dialog follows this branch, so close the refresh modal ourselves.
45
+ ctx.ui.dialog.clear()
46
+ if (ok) {
47
+ ctx.ui.toast.show({
48
+ variant: "info",
49
+ message: "Reloaded. Run /connect-providers again to connect a provider.",
50
+ })
51
+ }
52
+ }
53
+ return
54
+ }
55
+
56
+ let refreshRequested = false
57
+ const selected = await ctx.ui.dialog.select<string>({
32
58
  title: "Connect providers",
33
- message:
34
- "No providers from the registry are available. Force a refresh to re-fetch the registry, then run /connect-providers again.",
35
- label: { confirm: "Force refresh" },
59
+ options: integrations.map((integration) => ({
60
+ title: integration.name,
61
+ value: integration.id,
62
+ footer: footer(integration),
63
+ })),
64
+ actions: [
65
+ {
66
+ title: "Force refresh",
67
+ bind: "ctrl+r",
68
+ selection: "none",
69
+ onTrigger: () => {
70
+ refreshRequested = true
71
+ ctx.ui.dialog.clear()
72
+ },
73
+ },
74
+ ],
36
75
  })
37
- if (confirmed) {
38
- if (await forceRefresh(ctx)) {
39
- ctx.ui.toast.show({ variant: "info", message: "Reloaded. Run /connect-providers again to connect a provider." })
40
- }
76
+
77
+ // The dialog is static, so a triggered action only records intent and closes
78
+ // the prompt; the work (and the re-open with fresh data) happens out here.
79
+ if (refreshRequested) {
80
+ await refreshWithProgress(ctx)
81
+ continue
41
82
  }
42
- return
43
- }
83
+ if (selected === undefined) return
44
84
 
45
- const selected = await ctx.ui.dialog.select<string>({
46
- title: "Connect providers",
47
- options: integrations.map((integration) => ({
48
- title: integration.name,
49
- value: integration.id,
50
- footer: footer(integration),
51
- })),
52
- actions: [
53
- {
54
- title: "Force refresh",
55
- bind: "ctrl+r",
56
- selection: "none",
57
- onTrigger: () => void forceRefresh(ctx),
58
- },
59
- ],
60
- })
61
- if (selected === undefined) return
85
+ const integration = integrations.find((item) => item.id === selected)
86
+ if (integration === undefined) return
62
87
 
63
- const integration = integrations.find((item) => item.id === selected)
64
- if (integration === undefined) return
88
+ await openProvider(ctx, integration)
89
+ return
90
+ }
91
+ }
65
92
 
66
- await openProvider(ctx, integration)
93
+ /**
94
+ * Show the "Refreshing registry…" modal and force a refresh.
95
+ *
96
+ * The modal is intentionally **not** cleared here: clearing it the moment the
97
+ * refresh resolves raced the renderer and turned it into a sub-frame flash the
98
+ * user never sees. The caller closes it explicitly (the empty state, which opens
99
+ * no follow-up dialog) or lets the reopened dialog replace it (the list state),
100
+ * so it stays up for the whole refresh.
101
+ */
102
+ async function refreshWithProgress(ctx: Context): Promise<boolean> {
103
+ void ctx.ui.dialog.alert({ title: "Connect providers", message: "Refreshing registry…" })
104
+ return forceRefresh(ctx)
67
105
  }
68
106
 
69
107
  /**
@@ -71,8 +109,23 @@ export async function connectProviders(ctx: Context): Promise<void> {
71
109
  * provider list. Returns whether it succeeded. A failure only surfaces as an
72
110
  * error toast: the previously registered providers stay usable, so nothing is
73
111
  * invalidated.
112
+ *
113
+ * Single-flight: concurrent callers share one refresh. The blocking modal makes
114
+ * a second trigger hard to reach, but the command palette is `mode: "global"`
115
+ * and can still re-enter this flow, so the guard keeps network work idempotent.
74
116
  */
75
- export async function forceRefresh(ctx: Context): Promise<boolean> {
117
+ let refreshInFlight: Promise<boolean> | undefined
118
+
119
+ export function forceRefresh(ctx: Context): Promise<boolean> {
120
+ if (refreshInFlight) return refreshInFlight
121
+ const pending = doRefresh(ctx).finally(() => {
122
+ if (refreshInFlight === pending) refreshInFlight = undefined
123
+ })
124
+ refreshInFlight = pending
125
+ return pending
126
+ }
127
+
128
+ async function doRefresh(ctx: Context): Promise<boolean> {
76
129
  try {
77
130
  const result = await ctx.client.rpc(registryRpc).refresh({})
78
131
  if (!result.ok) {
@@ -80,7 +133,10 @@ export async function forceRefresh(ctx: Context): Promise<boolean> {
80
133
  ctx.ui.toast.show({ variant: "error", message: `Registry refresh failed: ${detail}` })
81
134
  return false
82
135
  }
83
- ctx.data.location.integration.invalidate(ctx.location)
136
+ // `invalidate` alone only drops the sync marker — it does not refetch, so
137
+ // `list()` would keep returning the stale integrations. `reloadIntegrations`
138
+ // also awaits the sync, so the reopened list shows the freshly registered set.
139
+ await reloadIntegrations(ctx)
84
140
  ctx.ui.toast.show({
85
141
  variant: "success",
86
142
  message: `Registry refreshed: ${result.providers ?? 0} providers, ${result.models ?? 0} models`,
@@ -92,45 +148,141 @@ export async function forceRefresh(ctx: Context): Promise<boolean> {
92
148
  }
93
149
  }
94
150
 
151
+ /**
152
+ * Account manager. Loops so mutations (add / activate / rename / delete) land on
153
+ * a freshly re-read account list while the prompt stays open, matching the
154
+ * built-in behaviour. Returns when the user backs out.
155
+ */
95
156
  async function openProvider(ctx: Context, integration: IntegrationInfo): Promise<void> {
96
- const credentials = credentialConnections(integration)
97
- const active = credentials[0]
157
+ while (true) {
158
+ const current = integrationByID(ctx, integration.id) ?? integration
159
+ const credentials = credentialConnections(current)
160
+ const active = credentials[0]
98
161
 
99
- if (credentials.length === 0) {
100
- await connectKey(ctx, integration)
101
- return
162
+ if (credentials.length === 0) {
163
+ await connectKey(ctx, current)
164
+ return
165
+ }
166
+
167
+ let rename: string | undefined
168
+ let remove: string | undefined
169
+ const choice = await ctx.ui.dialog.select<string>({
170
+ title: current.name,
171
+ current: active?.id,
172
+ options: [
173
+ {
174
+ title: "Add account",
175
+ value: ADD_ACCOUNT,
176
+ description: "Paste another API key; it becomes the active account",
177
+ },
178
+ ...credentials.toSorted((a, b) => a.label.localeCompare(b.label)).map((credential) => ({
179
+ title: credential.label,
180
+ value: credential.id,
181
+ category: "Accounts",
182
+ footer: credential.id === active?.id ? "active" : undefined,
183
+ })),
184
+ ],
185
+ actions: [
186
+ {
187
+ title: "rename",
188
+ bind: "ctrl+r",
189
+ selection: "required",
190
+ onTrigger: (value) => {
191
+ // Actions cannot be hidden while "Add account" is focused, so guard here.
192
+ if (value === ADD_ACCOUNT) return
193
+ rename = value
194
+ ctx.ui.dialog.clear()
195
+ },
196
+ },
197
+ {
198
+ title: "delete",
199
+ bind: "ctrl+d",
200
+ selection: "required",
201
+ onTrigger: (value) => {
202
+ if (value === ADD_ACCOUNT) return
203
+ remove = value
204
+ ctx.ui.dialog.clear()
205
+ },
206
+ },
207
+ ],
208
+ })
209
+
210
+ if (rename !== undefined) {
211
+ await renameAccount(ctx, current, rename)
212
+ continue
213
+ }
214
+ if (remove !== undefined) {
215
+ if (await deleteAccount(ctx, current, remove)) return
216
+ continue
217
+ }
218
+
219
+ if (choice === undefined) return
220
+
221
+ if (choice === ADD_ACCOUNT) {
222
+ await connectKey(ctx, current)
223
+ continue
224
+ }
225
+
226
+ if (choice === active?.id) continue
227
+ const credential = credentials.find((item) => item.id === choice)
228
+ try {
229
+ await ctx.client.credential.activate({ credentialID: choice })
230
+ await reloadIntegrations(ctx)
231
+ ctx.ui.toast.show({ variant: "success", message: `Activated ${credential?.label ?? choice}` })
232
+ } catch (error) {
233
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
234
+ }
102
235
  }
236
+ }
103
237
 
104
- const choice = await ctx.ui.dialog.select<string>({
105
- title: integration.name,
106
- current: active?.id,
107
- options: [
108
- {
109
- title: "Add account",
110
- value: ADD_ACCOUNT,
111
- description: "Paste another API key; it becomes the active account",
112
- },
113
- ...credentials.toSorted((a, b) => a.label.localeCompare(b.label)).map((credential) => ({
114
- title: credential.label,
115
- value: credential.id,
116
- category: "Accounts",
117
- footer: credential.id === active?.id ? "active" : undefined,
118
- })),
119
- ],
238
+ async function renameAccount(ctx: Context, integration: IntegrationInfo, credentialID: string): Promise<void> {
239
+ const credential = credentialConnections(integration).find((item) => item.id === credentialID)
240
+ if (credential === undefined) return
241
+
242
+ const value = await ctx.ui.dialog.prompt({
243
+ title: "Rename account",
244
+ placeholder: "Account name",
245
+ value: credential.label,
120
246
  })
121
- if (choice === undefined) return
247
+ if (value === undefined) return
122
248
 
123
- if (choice === ADD_ACCOUNT) {
124
- await connectKey(ctx, integration)
125
- return
249
+ const label = value.trim()
250
+ if (label === "" || label === credential.label) return
251
+
252
+ try {
253
+ await ctx.client.credential.update({ credentialID, label })
254
+ await reloadIntegrations(ctx)
255
+ ctx.ui.toast.show({ variant: "success", message: `Renamed to ${label}` })
256
+ } catch (error) {
257
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
126
258
  }
259
+ }
260
+
261
+ /** Returns whether the manager should close (the last account was removed). */
262
+ async function deleteAccount(ctx: Context, integration: IntegrationInfo, credentialID: string): Promise<boolean> {
263
+ const credentials = credentialConnections(integration)
264
+ const credential = credentials.find((item) => item.id === credentialID)
265
+ if (credential === undefined) return false
127
266
 
128
- const credential = credentials.find((item) => item.id === choice)
267
+ const confirmed = await ctx.ui.dialog.confirm({
268
+ title: "Delete account",
269
+ message: `Delete "${credential.label}" from ${integration.name}?`,
270
+ label: { confirm: "Delete", cancel: "Cancel" },
271
+ })
272
+ if (confirmed !== true) return false
273
+
274
+ const last = credentials.length === 1
129
275
  try {
130
- await ctx.client.credential.activate({ credentialID: choice })
131
- ctx.ui.toast.show({ variant: "success", message: `Activated ${credential?.label ?? choice}` })
276
+ await ctx.client.credential.remove({ credentialID })
277
+ await reloadIntegrations(ctx)
278
+ if (last) {
279
+ ctx.ui.toast.show({ variant: "success", message: `Disconnected ${integration.name}` })
280
+ return true
281
+ }
282
+ return false
132
283
  } catch (error) {
133
284
  ctx.ui.toast.show({ variant: "error", message: message(error) })
285
+ return false
134
286
  }
135
287
  }
136
288
 
@@ -147,22 +299,36 @@ async function connectKey(ctx: Context, integration: IntegrationInfo): Promise<v
147
299
 
148
300
  try {
149
301
  await ctx.client.integration.connect.key({ integrationID: integration.id, key: key.trim() })
150
- ctx.data.location.integration.invalidate(ctx.location)
302
+ await reloadIntegrations(ctx)
151
303
  ctx.ui.toast.show({ variant: "success", message: `${integration.name} connected` })
152
304
  } catch (error) {
153
305
  ctx.ui.toast.show({ variant: "error", message: message(error) })
154
306
  }
155
307
  }
156
308
 
309
+ /** Drop the cached integration list and re-read it so the loop sees fresh accounts. */
310
+ async function reloadIntegrations(ctx: Context): Promise<void> {
311
+ ctx.data.location.integration.invalidate(ctx.location)
312
+ try {
313
+ await ctx.data.location.integration.sync(ctx.location)
314
+ } catch {
315
+ // A failed sync still leaves `list()` readable; the next action retries.
316
+ }
317
+ }
318
+
157
319
  function ownIntegrations(ctx: Context): IntegrationInfo[] {
158
320
  return (ctx.data.location.integration.list(ctx.location) ?? []).filter(
159
321
  (integration) => integration.metadata?.source === INTEGRATION_SOURCE,
160
322
  )
161
323
  }
162
324
 
163
- function credentialConnections(integration: IntegrationInfo): Array<Extract<Connection, { type: "credential" }>> {
325
+ function integrationByID(ctx: Context, id: string): IntegrationInfo | undefined {
326
+ return ownIntegrations(ctx).find((integration) => integration.id === id)
327
+ }
328
+
329
+ function credentialConnections(integration: IntegrationInfo): CredentialConnection[] {
164
330
  return integration.connections.filter(
165
- (connection): connection is Extract<Connection, { type: "credential" }> => connection.type === "credential",
331
+ (connection): connection is CredentialConnection => connection.type === "credential",
166
332
  )
167
333
  }
168
334