@harars/opencode-switch-openai-auth-plugin 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # opencode-switch-openai-auth-plugin
2
+
3
+ OpenCode TUI plugin for saving and switching between multiple OpenAI OAuth accounts with a single `/switch` command.
4
+
5
+ ## Features
6
+
7
+ - one `/switch` command for login, switch, and remove
8
+ - reuses the native OpenCode OpenAI OAuth flow
9
+ - stores saved accounts in a plugin-owned JSON file
10
+ - switches only `auth.json.openai` when you pick an account
11
+ - keeps saved-account removal separate from active auth
12
+ - supports multiple credentials for the same email by keying entries from the refresh token hash
13
+
14
+ ## Behavior
15
+
16
+ The `/switch` dialog can show:
17
+
18
+ - `login`
19
+ - saved accounts
20
+ - `logout`
21
+
22
+ Saved accounts are displayed with:
23
+
24
+ - title: email, or account id, or generated fallback key
25
+ - footer: `Current` for the active credential
26
+
27
+ `login` behavior:
28
+
29
+ - calls the native OpenCode provider auth flow for `openai`
30
+ - rereads the active OpenAI auth after OAuth completes
31
+ - saves that credential into `auth-switch/accounts.json`
32
+
33
+ `switch` behavior:
34
+
35
+ - writes the selected saved credential into `auth.json.openai`
36
+ - refreshes host auth state when runtime support is available
37
+
38
+ `logout` behavior:
39
+
40
+ - removes a saved credential from the plugin store
41
+ - does not delete the currently active OpenAI auth from `auth.json`
42
+
43
+ ## Storage
44
+
45
+ Plugin account store locations:
46
+
47
+ - Linux: `~/.local/share/opencode/auth-switch/accounts.json`
48
+ - macOS: `~/Library/Application Support/opencode/auth-switch/accounts.json`
49
+ - Windows: `%LOCALAPPDATA%/opencode/auth-switch/accounts.json`
50
+
51
+ `OPENCODE_TEST_HOME` is supported for tests.
52
+
53
+ ## Local Development
54
+
55
+ Install dependencies:
56
+
57
+ ```bash
58
+ bun install
59
+ ```
60
+
61
+ Run checks:
62
+
63
+ ```bash
64
+ bun test
65
+ bunx tsc -p tsconfig.json --noEmit
66
+ ```
67
+
68
+ ## Loading The Plugin
69
+
70
+ After publishing to npm, add the package name directly to your OpenCode `tui.json`.
71
+
72
+ Example:
73
+
74
+ ```json
75
+ {
76
+ "plugin": [
77
+ "@harars/opencode-switch-openai-auth-plugin"
78
+ ],
79
+ "plugin_enabled": {
80
+ "harars.switch-auth": true
81
+ }
82
+ }
83
+ ```
84
+
85
+ This package is published as source files so OpenCode can load the TUI entry directly from the installed package.
86
+
87
+ For local development, you can still point `tui.json` at a local file path.
88
+
89
+ Example local file setup:
90
+
91
+ ```json
92
+ {
93
+ "plugin": [
94
+ "file:///absolute/path/to/opencode-switch-openai-auth-plugin/src/tui.tsx"
95
+ ],
96
+ "plugin_enabled": {
97
+ "harars.switch-auth": true
98
+ }
99
+ }
100
+ ```
101
+
102
+ ## Package
103
+
104
+ - package name: `@harars/opencode-switch-openai-auth-plugin`
105
+ - plugin id: `harars.switch-auth`
106
+ - license: MIT
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@harars/opencode-switch-openai-auth-plugin",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode TUI plugin for switching saved OpenAI OAuth accounts",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/HArars/opencode-switch-openai-auth-plugin.git"
8
+ },
9
+ "homepage": "https://github.com/HArars/opencode-switch-openai-auth-plugin#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/HArars/opencode-switch-openai-auth-plugin/issues"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "plugin",
16
+ "tui",
17
+ "openai",
18
+ "oauth",
19
+ "auth",
20
+ "account-switcher"
21
+ ],
22
+ "type": "module",
23
+ "license": "MIT",
24
+ "main": "./src/index.ts",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./tui": "./src/tui.tsx"
28
+ },
29
+ "engines": {
30
+ "opencode": "^1.0.0"
31
+ },
32
+ "oc-plugin": [["tui", {
33
+ "compact": true
34
+ }]],
35
+ "scripts": {
36
+ "build": "bun ./scripts/build.ts",
37
+ "typecheck": "bunx tsc -p tsconfig.json --noEmit",
38
+ "test": "bun test"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "peerDependencies": {
46
+ "@opencode-ai/plugin": "^1.0.0"
47
+ },
48
+ "dependencies": {
49
+ "@opentui/core": "^0.1.93",
50
+ "@opentui/solid": "^0.1.93",
51
+ "solid-js": "^1.9.12"
52
+ },
53
+ "devDependencies": {
54
+ "bun-types": "^1.3.11",
55
+ "typescript": "^6.0.2"
56
+ }
57
+ }
package/src/format.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { StoredAccount } from "./types"
2
+
3
+ function short(id?: string) {
4
+ if (!id) return
5
+ if (id.length <= 12) return id
6
+ return `${id.slice(0, 6)}...${id.slice(-4)}`
7
+ }
8
+
9
+ export function accountTitle(account: StoredAccount) {
10
+ return account.email || account.accountId || account.id
11
+ }
12
+
13
+ export function accountDescription(account: StoredAccount, current: boolean) {
14
+ return short(account.accountId)
15
+ }
16
+
17
+ export function accountFooter(current: boolean) {
18
+ return current ? "Current" : undefined
19
+ }
20
+
21
+ export function loginTitle() {
22
+ return "login"
23
+ }
24
+
25
+ export function logoutTitle(has: boolean) {
26
+ return has ? "logout" : "logout unavailable"
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./tui"
package/src/login.tsx ADDED
@@ -0,0 +1,297 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { TextAttributes } from "@opentui/core"
3
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
+ import { useKeyboard } from "@opentui/solid"
5
+ import type { OAuthAuthz, ProviderMethod, ProviderPrompt } from "./types"
6
+ import { readCurrentAuth, upsertSavedAccount } from "./store"
7
+
8
+ type OAuthMethod = {
9
+ index: number
10
+ method: ProviderMethod
11
+ }
12
+
13
+ function visible(prompt: ProviderPrompt, values: Record<string, string>) {
14
+ if (!prompt.when) return true
15
+ const cur = values[prompt.when.key]
16
+ if (prompt.when.op === "eq") return cur === prompt.when.value
17
+ return cur !== prompt.when.value
18
+ }
19
+
20
+ function same(prev: Awaited<ReturnType<typeof readCurrentAuth>>, next: Awaited<ReturnType<typeof readCurrentAuth>>) {
21
+ if (!prev || !next) return false
22
+ return prev.refresh === next.refresh
23
+ }
24
+
25
+ function clip(text: string) {
26
+ if (process.stdout.isTTY) {
27
+ const base64 = Buffer.from(text).toString("base64")
28
+ const osc52 = `\x1b]52;c;${base64}\x07`
29
+ const seq = process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
30
+ process.stdout.write(seq)
31
+ }
32
+
33
+ const cmds = process.platform === "darwin"
34
+ ? [["pbcopy"]]
35
+ : process.platform === "win32"
36
+ ? [["clip"]]
37
+ : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
38
+
39
+ return Promise.any(
40
+ cmds.map(async (cmd) => {
41
+ const proc = Bun.spawn({
42
+ cmd,
43
+ stdin: "pipe",
44
+ stdout: "ignore",
45
+ stderr: "ignore",
46
+ })
47
+ await proc.stdin.write(new TextEncoder().encode(text))
48
+ proc.stdin.end()
49
+ const code = await proc.exited
50
+ if (code !== 0) throw new Error("copy failed")
51
+ }),
52
+ ).catch(() => {})
53
+ }
54
+
55
+ function target(authz: OAuthAuthz) {
56
+ return authz.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? authz.url
57
+ }
58
+
59
+ function bind(api: TuiPluginApi, authz: OAuthAuthz) {
60
+ useKeyboard((evt) => {
61
+ if (evt.name !== "c" || evt.ctrl || evt.meta) return
62
+ evt.preventDefault()
63
+ evt.stopPropagation()
64
+ void clip(target(authz))
65
+ .then(() => api.ui.toast({ variant: "info", message: "Copied to clipboard" }))
66
+ })
67
+ }
68
+
69
+ function WaitView(props: { api: TuiPluginApi; title: string; authz: OAuthAuthz }) {
70
+ bind(props.api, props.authz)
71
+ return (
72
+ <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
73
+ <text attributes={TextAttributes.BOLD}>{props.title}</text>
74
+ <text>{props.authz.instructions}</text>
75
+ <text>{props.authz.url}</text>
76
+ <text>Waiting for authorization...</text>
77
+ <text>Press c to copy the link</text>
78
+ </box>
79
+ )
80
+ }
81
+
82
+ function CodePrompt(props: {
83
+ api: TuiPluginApi
84
+ title: string
85
+ authz: OAuthAuthz
86
+ onConfirm: (value: string) => void
87
+ onCancel: () => void
88
+ }) {
89
+ bind(props.api, props.authz)
90
+ return (
91
+ <props.api.ui.DialogPrompt
92
+ title={props.title}
93
+ placeholder="Authorization code"
94
+ onConfirm={props.onConfirm}
95
+ onCancel={props.onCancel}
96
+ description={() => (
97
+ <box gap={1}>
98
+ <text>{props.authz.instructions}</text>
99
+ <text>{props.authz.url}</text>
100
+ <text>Press c to copy the code</text>
101
+ </box>
102
+ )}
103
+ />
104
+ )
105
+ }
106
+
107
+ function wait(api: TuiPluginApi, title: string, authz: OAuthAuthz, run: () => Promise<void>) {
108
+ api.ui.dialog.replace(() => <WaitView api={api} title={title} authz={authz} />)
109
+ void run()
110
+ }
111
+
112
+ async function choose(api: TuiPluginApi, methods: OAuthMethod[]) {
113
+ if (methods.length === 1) return 0
114
+ return await new Promise<number | null>((resolve) => {
115
+ api.ui.dialog.replace(
116
+ () => (
117
+ <api.ui.DialogSelect
118
+ title="Select auth method"
119
+ options={methods.map((item, index) => ({ title: item.method.label, value: index }))}
120
+ onSelect={(item) => resolve(item.value)}
121
+ />
122
+ ),
123
+ () => resolve(null),
124
+ )
125
+ })
126
+ }
127
+
128
+ async function ask(api: TuiPluginApi, title: string, prompt: ProviderPrompt) {
129
+ if (prompt.type === "text") {
130
+ return await new Promise<string | null>((resolve) => {
131
+ api.ui.dialog.replace(
132
+ () => (
133
+ <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
134
+ <api.ui.DialogPrompt
135
+ title={title}
136
+ placeholder={prompt.placeholder}
137
+ onConfirm={(value) => resolve(value)}
138
+ onCancel={() => resolve(null)}
139
+ description={() => <text>{prompt.message}</text>}
140
+ />
141
+ </box>
142
+ ),
143
+ () => resolve(null),
144
+ )
145
+ })
146
+ }
147
+ return await new Promise<string | null>((resolve) => {
148
+ api.ui.dialog.replace(
149
+ () => (
150
+ <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
151
+ <api.ui.DialogSelect
152
+ title={prompt.message}
153
+ options={(prompt.options ?? []).map((item) => ({
154
+ title: item.label,
155
+ value: item.value,
156
+ description: item.hint,
157
+ }))}
158
+ onSelect={(item) => resolve(item.value)}
159
+ />
160
+ </box>
161
+ ),
162
+ () => resolve(null),
163
+ )
164
+ })
165
+ }
166
+
167
+ async function prompts(api: TuiPluginApi, title: string, method: ProviderMethod) {
168
+ const values: Record<string, string> = {}
169
+ for (const prompt of method.prompts ?? []) {
170
+ if (!visible(prompt, values)) continue
171
+ const value = await ask(api, title, prompt)
172
+ if (value == null) return
173
+ values[prompt.key] = value
174
+ }
175
+ return values
176
+ }
177
+
178
+ async function save(api: TuiPluginApi, prev?: Awaited<ReturnType<typeof readCurrentAuth>>) {
179
+ let activeUpdated = false
180
+ try {
181
+ const client = api.client as TuiPluginApi["client"] & {
182
+ sync?: { bootstrap?: () => Promise<void> }
183
+ }
184
+ if (typeof api.client.instance.dispose === "function") {
185
+ await api.client.instance.dispose()
186
+ }
187
+ if (typeof client.sync?.bootstrap === "function") {
188
+ await client.sync.bootstrap()
189
+ }
190
+ const auth = await readCurrentAuth()
191
+ if (!auth) {
192
+ api.ui.toast({ variant: "error", message: "Login completed, but saved account could not be loaded" })
193
+ return false
194
+ }
195
+ activeUpdated = true
196
+ if (same(prev, auth)) {
197
+ api.ui.toast({ variant: "warning", message: "Login completed, but OpenAI account did not change" })
198
+ return false
199
+ }
200
+ await upsertSavedAccount(auth)
201
+ return true
202
+ } catch {
203
+ api.ui.toast({
204
+ variant: "error",
205
+ message: activeUpdated
206
+ ? "Login completed, but saving the account failed"
207
+ : "Login completed, but account sync failed",
208
+ })
209
+ return false
210
+ }
211
+ }
212
+
213
+ async function code(api: TuiPluginApi, index: number, method: ProviderMethod, authz: OAuthAuthz) {
214
+ const prev = await readCurrentAuth()
215
+ const ok = await new Promise<boolean>((resolve) => {
216
+ api.ui.dialog.replace(
217
+ () => (
218
+ <CodePrompt
219
+ api={api}
220
+ title={method.label}
221
+ authz={authz}
222
+ onConfirm={async (value) => {
223
+ const res = await api.client.provider.oauth.callback({ providerID: "openai", method: index, code: value })
224
+ resolve(!res.error)
225
+ }}
226
+ onCancel={() => resolve(false)}
227
+ />
228
+ ),
229
+ () => resolve(false),
230
+ )
231
+ })
232
+ if (!ok) {
233
+ api.ui.toast({ variant: "error", message: "Login failed" })
234
+ return false
235
+ }
236
+ return save(api, prev)
237
+ }
238
+
239
+ async function auto(api: TuiPluginApi, index: number, method: ProviderMethod, authz: OAuthAuthz) {
240
+ const prev = await readCurrentAuth()
241
+ const ok = await new Promise<boolean>((resolve) => {
242
+ wait(api, method.label, authz, async () => {
243
+ const res = await api.client.provider.oauth.callback({ providerID: "openai", method: index })
244
+ resolve(!res.error)
245
+ })
246
+ })
247
+ if (!ok) {
248
+ api.ui.toast({ variant: "error", message: "Login failed" })
249
+ return false
250
+ }
251
+ return save(api, prev)
252
+ }
253
+
254
+ export async function hasLogin(api: TuiPluginApi) {
255
+ try {
256
+ const res = await api.client.provider.auth()
257
+ const methods = (res.data?.openai ?? []) as ProviderMethod[]
258
+ return methods.some((item) => item.type === "oauth")
259
+ } catch {
260
+ return false
261
+ }
262
+ }
263
+
264
+ export async function loginOpenAI(api: TuiPluginApi) {
265
+ try {
266
+ const auth = await api.client.provider.auth()
267
+ const methods = ((auth.data?.openai ?? []) as ProviderMethod[])
268
+ .map((method, index) => ({ method, index }))
269
+ .filter((item) => item.method.type === "oauth")
270
+ if (!methods.length) {
271
+ api.ui.toast({ variant: "error", message: "OpenAI OAuth login unavailable" })
272
+ return false
273
+ }
274
+ const index = await choose(api, methods)
275
+ if (index == null) return false
276
+ const picked = methods[index]
277
+ const method = picked.method
278
+ const inputs = await prompts(api, method.label, method)
279
+ if (method.prompts?.length && !inputs) return false
280
+ const authz = await api.client.provider.oauth.authorize({
281
+ providerID: "openai",
282
+ method: picked.index,
283
+ inputs,
284
+ })
285
+ if (authz.error || !authz.data) {
286
+ api.ui.toast({ variant: "error", message: "Login failed" })
287
+ return false
288
+ }
289
+ if (authz.data.method === "code") return code(api, picked.index, method, authz.data as OAuthAuthz)
290
+ if (authz.data.method === "auto") return auto(api, picked.index, method, authz.data as OAuthAuthz)
291
+ api.ui.toast({ variant: "error", message: "Unsupported auth method" })
292
+ return false
293
+ } catch {
294
+ api.ui.toast({ variant: "error", message: "Login failed" })
295
+ return false
296
+ }
297
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { Claims, OpenAIAuth, StoreEntry } from "./types"
3
+
4
+ function text(v: unknown) {
5
+ return typeof v === "string" && v.trim() ? v : undefined
6
+ }
7
+
8
+ function obj(v: unknown) {
9
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : undefined
10
+ }
11
+
12
+ export function parseJwtClaims(token: string): Claims {
13
+ const part = token.split(".")[1]
14
+ if (!part) return {}
15
+ try {
16
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Claims
17
+ } catch {
18
+ return {}
19
+ }
20
+ }
21
+
22
+ export function extractEmail(auth: OpenAIAuth) {
23
+ const claims = parseJwtClaims(auth.access)
24
+ const direct = text(claims["https://api.openai.com/profile.email"])
25
+ if (direct) return direct
26
+ return text(obj(claims["https://api.openai.com/profile"] as unknown)?.email)
27
+ }
28
+
29
+ export function extractPlan(auth: OpenAIAuth) {
30
+ return text(parseJwtClaims(auth.access)["https://api.openai.com/auth.chatgpt_plan_type"])
31
+ }
32
+
33
+ export function extractAccountId(auth: OpenAIAuth) {
34
+ if (text(auth.accountId)) return text(auth.accountId)
35
+ const claims = parseJwtClaims(auth.access)
36
+ const direct = text(claims.chatgpt_account_id)
37
+ if (direct) return direct
38
+ const namespaced = text(claims["https://api.openai.com/auth.chatgpt_account_id"])
39
+ if (namespaced) return namespaced
40
+ const orgs = claims.organizations
41
+ if (!Array.isArray(orgs) || !orgs.length) return
42
+ return text(obj(orgs[0])?.id)
43
+ }
44
+
45
+ export function fallback(refresh: string) {
46
+ return `fallback:${createHash("sha256").update(refresh).digest("hex").slice(0, 16)}`
47
+ }
48
+
49
+ export function key(auth: OpenAIAuth) {
50
+ return fallback(auth.refresh)
51
+ }
52
+
53
+ export function entryFromAuth(auth: OpenAIAuth, now = Date.now()): StoreEntry {
54
+ return {
55
+ key: key(auth),
56
+ email: extractEmail(auth),
57
+ accountId: extractAccountId(auth),
58
+ plan: extractPlan(auth),
59
+ savedAt: now,
60
+ auth: clean(auth),
61
+ }
62
+ }
63
+
64
+ export function clean(auth: OpenAIAuth): OpenAIAuth {
65
+ return {
66
+ type: "oauth",
67
+ refresh: auth.refresh,
68
+ access: auth.access,
69
+ expires: auth.expires,
70
+ ...(text(auth.accountId) ? { accountId: auth.accountId } : {}),
71
+ ...(text(auth.enterpriseUrl) ? { enterpriseUrl: auth.enterpriseUrl } : {}),
72
+ }
73
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,27 @@
1
+ import os from "node:os"
2
+ import path from "node:path"
3
+
4
+ export function dataPath() {
5
+ if (process.env.OPENCODE_TEST_HOME) {
6
+ return path.join(process.env.OPENCODE_TEST_HOME, ".local", "share", "opencode")
7
+ }
8
+ if (process.env.XDG_DATA_HOME) return path.join(process.env.XDG_DATA_HOME, "opencode")
9
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "opencode")
10
+ if (process.platform === "win32") {
11
+ const root = process.env.LOCALAPPDATA || process.env.APPDATA
12
+ if (root) return path.join(root, "opencode")
13
+ }
14
+ return path.join(os.homedir(), ".local", "share", "opencode")
15
+ }
16
+
17
+ export function authPath() {
18
+ return path.join(dataPath(), "auth.json")
19
+ }
20
+
21
+ export function storeDir() {
22
+ return path.join(dataPath(), "auth-switch")
23
+ }
24
+
25
+ export function storePath() {
26
+ return path.join(storeDir(), "accounts.json")
27
+ }
package/src/store.ts ADDED
@@ -0,0 +1,244 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import type { OpenAIAuth, StoreEntry, StoreFile, StoreLoad, StoredAccount } from "./types"
4
+ import { authPath, storeDir, storePath } from "./paths"
5
+ import { clean, entryFromAuth, extractAccountId, extractEmail, fallback } from "./parse"
6
+
7
+ function obj(v: unknown) {
8
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : undefined
9
+ }
10
+
11
+ function text(v: unknown) {
12
+ return typeof v === "string" && v.trim() ? v : undefined
13
+ }
14
+
15
+ function num(v: unknown) {
16
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined
17
+ }
18
+
19
+ export function validAuth(v: unknown): v is OpenAIAuth {
20
+ const item = obj(v)
21
+ if (!item) return false
22
+ if (item.type !== "oauth") return false
23
+ if (!text(item.refresh) || !text(item.access) || num(item.expires) === undefined) return false
24
+ if (item.accountId !== undefined && !text(item.accountId)) return false
25
+ if (item.enterpriseUrl !== undefined && !text(item.enterpriseUrl)) return false
26
+ return true
27
+ }
28
+
29
+ function validEntry(id: string, v: unknown): v is StoreEntry {
30
+ const item = obj(v)
31
+ if (!item || item.key !== id) return false
32
+ if (item.email !== undefined && !text(item.email)) return false
33
+ if (item.accountId !== undefined && !text(item.accountId)) return false
34
+ if (item.plan !== undefined && !text(item.plan)) return false
35
+ if (num(item.savedAt) === undefined) return false
36
+ if (item.lastUsedAt !== undefined && num(item.lastUsedAt) === undefined) return false
37
+ return validAuth(item.auth)
38
+ }
39
+
40
+ async function readJson(file: string) {
41
+ const data = Bun.file(file)
42
+ if (!(await data.exists())) return
43
+ const raw = (await data.text()).trim()
44
+ if (!raw) return
45
+ return JSON.parse(raw) as unknown
46
+ }
47
+
48
+ async function writeJson(file: string, value: unknown) {
49
+ await fs.mkdir(storeDir(), { recursive: true })
50
+ await Bun.write(file, `${JSON.stringify(value, null, 2)}\n`)
51
+ }
52
+
53
+ function hydrateAccount(id: string, item: StoreEntry): StoredAccount {
54
+ return {
55
+ ...item,
56
+ id,
57
+ email: item.email || extractEmail(item.auth),
58
+ accountId: item.accountId || extractAccountId(item.auth),
59
+ }
60
+ }
61
+
62
+ function normalizeEntries(entries: Record<string, unknown>) {
63
+ const normalized: Record<string, StoreEntry> = {}
64
+ let changed = false
65
+
66
+ for (const [id, value] of Object.entries(entries)) {
67
+ if (!validEntry(id, value)) {
68
+ const item = obj(value)
69
+ if (!item || !validAuth(item.auth)) {
70
+ changed = true
71
+ continue
72
+ }
73
+ const auth = clean(item.auth)
74
+ const nextKey = fallback(auth.refresh)
75
+ normalized[nextKey] = {
76
+ key: nextKey,
77
+ email: text(item.email) || extractEmail(auth),
78
+ accountId: text(item.accountId) || extractAccountId(auth),
79
+ plan: text(item.plan),
80
+ savedAt: num(item.savedAt) ?? Date.now(),
81
+ ...(num(item.lastUsedAt) !== undefined ? { lastUsedAt: num(item.lastUsedAt) } : {}),
82
+ auth,
83
+ }
84
+ changed = true
85
+ continue
86
+ }
87
+
88
+ const nextKey = fallback(value.auth.refresh)
89
+ if (nextKey !== id || value.key !== nextKey) changed = true
90
+ normalized[nextKey] = {
91
+ ...value,
92
+ key: nextKey,
93
+ auth: clean(value.auth),
94
+ }
95
+ }
96
+
97
+ return { normalized, changed }
98
+ }
99
+
100
+ export async function readCurrentAuth() {
101
+ const raw = await readJson(authPath())
102
+ const data = obj(raw)
103
+ if (!data) return
104
+ const auth = data.openai
105
+ if (!validAuth(auth)) return
106
+ return clean(auth)
107
+ }
108
+
109
+ export async function writeCurrentOpenAI(auth: OpenAIAuth) {
110
+ const raw = (await readJson(authPath())) ?? {}
111
+ const next = obj(raw) ?? {}
112
+ next.openai = clean(auth)
113
+ await fs.mkdir(path.dirname(authPath()), { recursive: true })
114
+ await Bun.write(authPath(), `${JSON.stringify(next, null, 2)}\n`)
115
+ }
116
+
117
+ export async function readStore(): Promise<StoreLoad> {
118
+ const raw = await readJson(storePath())
119
+ if (raw === undefined) return { ok: false, reason: "missing" }
120
+ const root = obj(raw)
121
+ const map = obj(root?.openai)
122
+ if (!root || root.version !== 1 || !map) return { ok: false, reason: "malformed" }
123
+ const { normalized, changed } = normalizeEntries(map)
124
+ if (changed) {
125
+ await writeStore({ version: 1, openai: normalized })
126
+ }
127
+ return {
128
+ ok: true,
129
+ store: {
130
+ version: 1,
131
+ openai: normalized,
132
+ },
133
+ }
134
+ }
135
+
136
+ export async function writeStore(store: StoreFile) {
137
+ await writeJson(storePath(), store)
138
+ }
139
+
140
+ export async function readAllAccounts() {
141
+ const load = await readStore()
142
+ if (!load.ok) return load
143
+ return {
144
+ ok: true as const,
145
+ accounts: Object.entries(load.store.openai)
146
+ .filter(([id, item]) => validEntry(id, item))
147
+ .map(([id, item]) => hydrateAccount(id, item)),
148
+ }
149
+ }
150
+
151
+ export async function pruneInvalidAccounts() {
152
+ const load = await readStore()
153
+ if (!load.ok) return load
154
+ const next = Object.fromEntries(Object.entries(load.store.openai).filter(([id, item]) => validEntry(id, item)))
155
+ if (Object.keys(next).length !== Object.keys(load.store.openai).length) {
156
+ await writeStore({ version: 1, openai: next })
157
+ }
158
+ return {
159
+ ok: true as const,
160
+ accounts: Object.entries(next).map(([id, item]) => hydrateAccount(id, item)),
161
+ }
162
+ }
163
+
164
+ export async function currentAccountId() {
165
+ const auth = await readCurrentAuth()
166
+ if (!auth) return
167
+ return fallback(auth.refresh)
168
+ }
169
+
170
+ function match(list: StoredAccount[], auth: OpenAIAuth) {
171
+ return list.find((item) => {
172
+ if (item.auth.refresh === auth.refresh) return true
173
+ return item.id === fallback(auth.refresh)
174
+ })?.id
175
+ }
176
+
177
+ export async function upsertSavedAccount(auth: OpenAIAuth) {
178
+ const base = entryFromAuth(auth)
179
+ const load = await readStore()
180
+ const store = load.ok ? load.store : { version: 1 as const, openai: {} }
181
+ const list = Object.entries(store.openai)
182
+ const match = list.find(([id, item]) => {
183
+ if (!validEntry(id, item)) return false
184
+ return item.auth.refresh === auth.refresh || id === base.key
185
+ })
186
+ const prev = match?.[1]
187
+ const next: StoreEntry = {
188
+ ...base,
189
+ savedAt: prev?.savedAt ?? base.savedAt,
190
+ lastUsedAt: prev?.lastUsedAt,
191
+ }
192
+ const out = Object.fromEntries(
193
+ list.filter(([id]) => id !== match?.[0]).filter(([id, item]) => validEntry(id, item)),
194
+ ) as Record<string, StoreEntry>
195
+ out[next.key] = next
196
+ await writeStore({ version: 1, openai: out })
197
+ return { ...next, id: next.key }
198
+ }
199
+
200
+ function sort(list: StoredAccount[], cur?: string) {
201
+ return [...list].sort((a, b) => {
202
+ const ac = a.id === cur ? 1 : 0
203
+ const bc = b.id === cur ? 1 : 0
204
+ if (ac !== bc) return bc - ac
205
+ if ((b.lastUsedAt ?? 0) !== (a.lastUsedAt ?? 0)) return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)
206
+ if (b.savedAt !== a.savedAt) return b.savedAt - a.savedAt
207
+ return (a.email || a.accountId || a.id).localeCompare(b.email || b.accountId || b.id)
208
+ })
209
+ }
210
+
211
+ export async function listAccounts() {
212
+ const load = await pruneInvalidAccounts()
213
+ if (!load.ok) return load
214
+ const auth = await readCurrentAuth()
215
+ const cur = auth ? match(load.accounts, auth) ?? fallback(auth.refresh) : undefined
216
+ return { ok: true as const, accounts: sort(load.accounts, cur), current: cur }
217
+ }
218
+
219
+ export async function switchAccount(id: string) {
220
+ const load = await readStore()
221
+ if (!load.ok) throw new Error("account store unavailable")
222
+ const item = load.store.openai[id]
223
+ if (!validEntry(id, item)) throw new Error("saved account not found")
224
+ await writeCurrentOpenAI(item.auth)
225
+ await writeStore({
226
+ version: 1,
227
+ openai: {
228
+ ...load.store.openai,
229
+ [id]: {
230
+ ...item,
231
+ lastUsedAt: Date.now(),
232
+ },
233
+ },
234
+ })
235
+ }
236
+
237
+ export async function deleteSavedAccount(id: string) {
238
+ const load = await readStore()
239
+ if (!load.ok) throw new Error("account store unavailable")
240
+ if (!(id in load.store.openai)) throw new Error("saved account not found")
241
+ const next = { ...load.store.openai }
242
+ delete next[id]
243
+ await writeStore({ version: 1, openai: next })
244
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,194 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiCommand, TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { accountDescription, accountFooter, accountTitle, loginTitle, logoutTitle } from "./format"
4
+ import { hasLogin, loginOpenAI } from "./login"
5
+ import { deleteSavedAccount, listAccounts, switchAccount } from "./store"
6
+ import type { StoredAccount, SwitchAction, SwitchOption } from "./types"
7
+
8
+ let seq = 0
9
+
10
+ function frame(text: string) {
11
+ return (
12
+ <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
13
+ <text>{text}</text>
14
+ </box>
15
+ )
16
+ }
17
+
18
+ async function pick(api: Parameters<TuiPlugin>[0], list: StoredAccount[]) {
19
+ return await new Promise<string | null>((resolve) => {
20
+ api.ui.dialog.replace(
21
+ () => (
22
+ <api.ui.DialogSelect
23
+ title="Remove saved account"
24
+ options={list.map((item) => ({
25
+ title: accountTitle(item),
26
+ value: item.id,
27
+ category: accountDescription(item, false),
28
+ }))}
29
+ onSelect={(item) => resolve(item.value)}
30
+ />
31
+ ),
32
+ () => resolve(null),
33
+ )
34
+ })
35
+ }
36
+
37
+ async function confirm(api: Parameters<TuiPlugin>[0], title: string, message: string) {
38
+ return await new Promise<boolean>((resolve) => {
39
+ api.ui.dialog.replace(
40
+ () => (
41
+ <api.ui.DialogConfirm
42
+ title={title}
43
+ message={message}
44
+ onConfirm={() => resolve(true)}
45
+ onCancel={() => resolve(false)}
46
+ />
47
+ ),
48
+ () => resolve(false),
49
+ )
50
+ })
51
+ }
52
+
53
+ async function remove(api: Parameters<TuiPlugin>[0], list: StoredAccount[]) {
54
+ const id = await pick(api, list)
55
+ if (!id) return
56
+ const item = list.find((row) => row.id === id)
57
+ if (!item) return
58
+ const ok = await confirm(api, "Remove saved account", `Remove ${accountTitle(item)} from saved accounts?`)
59
+ if (!ok) return
60
+ try {
61
+ await deleteSavedAccount(id)
62
+ api.ui.toast({ variant: "success", message: "Saved account removed" })
63
+ } catch {
64
+ api.ui.toast({ variant: "error", message: "Failed to remove saved account" })
65
+ }
66
+ }
67
+
68
+ function options(list: StoredAccount[], current: string | undefined, canLogin: boolean): SwitchOption[] {
69
+ const rows = list.map((item) => ({
70
+ title: accountTitle(item),
71
+ value: { type: "account", id: item.id } as SwitchAction,
72
+ description: accountDescription(item, item.id === current),
73
+ footer: accountFooter(item.id === current),
74
+ category: "Accounts",
75
+ }))
76
+ return [
77
+ ...(canLogin ? [{ title: loginTitle(), value: { type: "login" } as SwitchAction }] : []),
78
+ ...rows,
79
+ ...(list.length
80
+ ? [
81
+ {
82
+ title: logoutTitle(true),
83
+ value: { type: "logout" } as SwitchAction,
84
+ description: "Remove a saved account",
85
+ category: "Actions",
86
+ },
87
+ ]
88
+ : []),
89
+ ]
90
+ }
91
+
92
+ async function open(api: Parameters<TuiPlugin>[0]) {
93
+ const id = ++seq
94
+ api.ui.dialog.setSize("large")
95
+ api.ui.dialog.replace(() => frame("Loading accounts..."))
96
+
97
+ const [store, canLogin] = await Promise.all([listAccounts(), hasLogin(api)])
98
+ if (id !== seq) return
99
+
100
+ if (!store.ok && store.reason === "malformed") {
101
+ api.ui.toast({ variant: "error", message: "Failed to load account store" })
102
+ }
103
+
104
+ const accounts = store.ok ? store.accounts : []
105
+ const current = store.ok ? store.current : undefined
106
+ const rows = options(accounts, current, canLogin)
107
+
108
+ if (!rows.length) {
109
+ api.ui.dialog.replace(() => frame("OpenAI login unavailable and no saved accounts found"))
110
+ return
111
+ }
112
+
113
+ if (!accounts.length && canLogin) {
114
+ api.ui.dialog.replace(() => (
115
+ <api.ui.DialogSelect
116
+ title="Switch OpenAI account"
117
+ options={rows}
118
+ placeholder="Search"
119
+ onSelect={(item) => void act(api, item.value, accounts)}
120
+ />
121
+ ))
122
+ return
123
+ }
124
+
125
+ api.ui.dialog.replace(() => (
126
+ <api.ui.DialogSelect
127
+ title="Switch OpenAI account"
128
+ options={rows}
129
+ placeholder="Search"
130
+ current={current ? ({ type: "account", id: current } as SwitchAction) : undefined}
131
+ onSelect={(item) => void act(api, item.value, accounts)}
132
+ />
133
+ ))
134
+ }
135
+
136
+ async function act(api: Parameters<TuiPlugin>[0], action: SwitchAction, list: StoredAccount[]) {
137
+ if (action.type === "login") {
138
+ const ok = await loginOpenAI(api)
139
+ if (ok) {
140
+ api.ui.toast({ variant: "success", message: "Account saved" })
141
+ await open(api)
142
+ }
143
+ return
144
+ }
145
+ if (action.type === "logout") {
146
+ await remove(api, list)
147
+ await open(api)
148
+ return
149
+ }
150
+ try {
151
+ await switchAccount(action.id)
152
+ } catch {
153
+ api.ui.toast({ variant: "error", message: "Failed to switch account" })
154
+ return
155
+ }
156
+
157
+ try {
158
+ const client = api.client as typeof api.client & {
159
+ sync?: { bootstrap?: () => Promise<void> }
160
+ }
161
+ if (typeof api.client.instance.dispose === "function") {
162
+ await api.client.instance.dispose()
163
+ }
164
+ if (typeof client.sync?.bootstrap === "function") {
165
+ await client.sync.bootstrap()
166
+ }
167
+ api.ui.toast({ variant: "success", message: "Switched OpenAI account" })
168
+ api.ui.dialog.clear()
169
+ } catch {
170
+ api.ui.toast({
171
+ variant: "warning",
172
+ message: "Account file was switched, but session refresh failed",
173
+ })
174
+ }
175
+ }
176
+
177
+ const tui: TuiPlugin = async (api) => {
178
+ api.command.register(() => [
179
+ {
180
+ title: "Switch OpenAI account",
181
+ value: "openai.switch",
182
+ description: "Login, switch, or remove saved OpenAI accounts",
183
+ slash: { name: "switch" },
184
+ onSelect: () => {
185
+ void open(api)
186
+ },
187
+ } satisfies TuiCommand,
188
+ ])
189
+ }
190
+
191
+ export default {
192
+ id: "harars.switch-auth",
193
+ tui,
194
+ } satisfies TuiPluginModule & { id: string }
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ export type OpenAIAuth = {
2
+ type: "oauth"
3
+ refresh: string
4
+ access: string
5
+ expires: number
6
+ accountId?: string
7
+ enterpriseUrl?: string
8
+ }
9
+
10
+ export type StoreEntry = {
11
+ key: string
12
+ email?: string
13
+ accountId?: string
14
+ plan?: string
15
+ savedAt: number
16
+ lastUsedAt?: number
17
+ auth: OpenAIAuth
18
+ }
19
+
20
+ export type StoreFile = {
21
+ version: 1
22
+ openai: Record<string, StoreEntry>
23
+ }
24
+
25
+ export type StoredAccount = StoreEntry & {
26
+ id: string
27
+ }
28
+
29
+ export type SwitchAction =
30
+ | { type: "login" }
31
+ | { type: "account"; id: string }
32
+ | { type: "logout" }
33
+
34
+ export type SwitchOption = {
35
+ title: string
36
+ value: SwitchAction
37
+ description?: string
38
+ category?: string
39
+ footer?: string
40
+ }
41
+
42
+ export type Claims = Record<string, unknown>
43
+
44
+ export type StoreLoad =
45
+ | { ok: true; store: StoreFile }
46
+ | { ok: false; reason: "malformed" | "missing" }
47
+
48
+ export type ProviderPrompt = {
49
+ type: "text" | "select"
50
+ key: string
51
+ message: string
52
+ placeholder?: string
53
+ options?: Array<{ label: string; value: string; hint?: string }>
54
+ when?: {
55
+ key: string
56
+ op: "eq" | "neq"
57
+ value: string
58
+ }
59
+ }
60
+
61
+ export type ProviderMethod = {
62
+ type: "oauth" | "api"
63
+ label: string
64
+ prompts?: ProviderPrompt[]
65
+ }
66
+
67
+ export type OAuthAuthz = {
68
+ url: string
69
+ method: "auto" | "code"
70
+ instructions: string
71
+ }