@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 +21 -0
- package/README.md +106 -0
- package/package.json +57 -0
- package/src/format.ts +27 -0
- package/src/index.ts +1 -0
- package/src/login.tsx +297 -0
- package/src/parse.ts +73 -0
- package/src/paths.ts +27 -0
- package/src/store.ts +244 -0
- package/src/tui.tsx +194 -0
- package/src/types.ts +71 -0
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
|
+
}
|