From 2b030a281c16e33716d6fda231fdab553dec48b3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 21:58:21 +0000 Subject: [PATCH 01/12] feat(auth): add multi-account support with auto-rotation - New auth store format: { provider: { accounts: {}, activeAccount } } - Auto-migration from legacy format (single credential per provider) - New functions: add, getAccounts, use, setEnabled, getNextAccount - CLI updates: interactive account selection, enable/disable accounts - Round-robin auto-rotation for rate-limit handling --- packages/opencode/src/auth/index.ts | 255 +++++++++++++++++++++++--- packages/opencode/src/cli/cmd/auth.ts | 242 +++++++++++++++++++++--- 2 files changed, 449 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac8..5087b7c7440 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -13,6 +13,7 @@ export namespace Auth { expires: z.number(), accountId: z.string().optional(), enterpriseUrl: z.string().optional(), + email: z.string().optional(), // For multi-account identification }) .meta({ ref: "OAuth" }) @@ -20,6 +21,7 @@ export namespace Auth { .object({ type: z.literal("api"), key: z.string(), + email: z.string().optional(), // For multi-account identification }) .meta({ ref: "ApiAuth" }) @@ -34,37 +36,246 @@ export namespace Auth { export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) export type Info = z.infer + // Multi-account storage format + export type AccountStore = { + [provider: string]: { + accounts: { + [accountId: string]: Info & { disabled?: boolean } + } + // Current active account for this provider + activeAccount?: string + } + } + const filepath = path.join(Global.Path.data, "auth.json") - export async function get(providerID: string) { - const auth = await all() - return auth[providerID] + /** + * Get credential for a provider. + * Uses activeAccount if set, otherwise returns first available. + */ + export async function get(providerID: string): Promise { + const store = await load() + const provider = store[providerID] + + if (!provider || !provider.accounts) return undefined + + // Use active account if set + if (provider.activeAccount && provider.accounts[provider.activeAccount]) { + return provider.accounts[provider.activeAccount] + } + + // Otherwise, find first non-disabled account + for (const [id, info] of Object.entries(provider.accounts)) { + if (!info.disabled) return info + } + + return undefined } - export async function all(): Promise> { - const file = Bun.file(filepath) - const data = await file.json().catch(() => ({}) as Record) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data - return acc - }, - {} as Record, - ) + /** + * Get all accounts for a provider. + */ + export async function getAccounts(providerID: string): Promise> { + const store = await load() + const provider = store[providerID] + return provider?.accounts ?? {} + } + + /** + * List all providers and their accounts. + */ + export async function all(): Promise { + return await load() + } + + /** + * Add a new credential to a provider. + * Automatically creates a new account entry. + * Returns the account ID (email or generated). + */ + export async function add(providerID: string, info: Info): Promise { + const store = await load() + + if (!store[providerID]) { + store[providerID] = { accounts: {} } + } + + // Generate account ID from email if available, otherwise use timestamp + let accountId = "default" + if ("email" in info && info.email) { + accountId = info.email + } else if ("refresh" in info && info.refresh) { + // For OAuth, use a hash of refresh token + accountId = `oauth-${Date.now()}` + } else { + accountId = `key-${Date.now()}` + } + + store[providerID].accounts[accountId] = info + + // If this is the first account, set as active + if (!store[providerID].activeAccount) { + store[providerID].activeAccount = accountId + } + + await save(store) + return accountId + } + + /** + * Set credential (alias for add for compatibility) + */ + export async function set(providerID: string, info: Info, account?: string) { + // For backwards compatibility: if account is provided, use it + if (account) { + const store = await load() + if (!store[providerID]) { + store[providerID] = { accounts: {} } + } + store[providerID].accounts[account] = info + if (!store[providerID].activeAccount) { + store[providerID].activeAccount = account + } + await save(store) + } else { + // Otherwise add as new account + await add(providerID, info) + } + } + + /** + * Remove an account from a provider. + * If account is "all" or not specified, removes all. + */ + export async function remove(providerID: string, account?: string) { + const store = await load() + const provider = store[providerID] + + if (!provider) return + + if (!account) { + // Remove all accounts for this provider + delete store[providerID] + } else if (account === "all") { + delete store[providerID] + } else { + delete provider.accounts[account] + + // If we removed the active account, switch to another + if (provider.activeAccount === account) { + const remaining = Object.keys(provider.accounts) + provider.activeAccount = remaining[0] ?? undefined + } + } + + await save(store) + } + + /** + * List all accounts for a provider. + */ + export async function list(providerID: string): Promise { + const store = await load() + const provider = store[providerID] + return provider ? Object.keys(provider.accounts) : [] + } + + /** + * Get active account for a provider. + */ + export async function getActiveAccount(providerID: string): Promise { + const store = await load() + return store[providerID]?.activeAccount + } + + /** + * Set active account for a provider. + */ + export async function use(providerID: string, account: string) { + const store = await load() + const provider = store[providerID] + + if (!provider || !provider.accounts[account]) { + throw new Error(`Account ${account} not found for provider ${providerID}`) + } + + provider.activeAccount = account + await save(store) + } + + /** + * Enable/disable an account (for rotation). + */ + export async function setEnabled(providerID: string, account: string, enabled: boolean) { + const store = await load() + const provider = store[providerID] + + if (!provider || !provider.accounts[account]) return + + provider.accounts[account].disabled = !enabled + + // If we disabled the active account, switch to another + if (!enabled && provider.activeAccount === account) { + const remaining = Object.keys(provider.accounts).filter(a => !provider.accounts[a].disabled) + provider.activeAccount = remaining[0] ?? undefined + } + + await save(store) + } + + /** + * Get next available account (for auto-rotation on rate-limit). + */ + export async function getNextAccount(providerID: string): Promise<{ account: string, info: Info } | undefined> { + const store = await load() + const provider = store[providerID] + + if (!provider || !provider.accounts) return undefined + + const accounts = Object.entries(provider.accounts).filter(([_, info]) => !info.disabled) + + if (accounts.length === 0) return undefined + + // Simple round-robin: switch to next account + const currentActive = provider.activeAccount + const currentIndex = accounts.findIndex(([id]) => id === currentActive) + const nextIndex = (currentIndex + 1) % accounts.length + + const [account, info] = accounts[nextIndex] + provider.activeAccount = account + await save(store) + + return { account, info } + } + + /** + * Legacy compatibility: convert old format to new. + */ + async function migrateIfNeeded(store: AccountStore): Promise { + // Check if it's in legacy format (direct Info objects instead of { accounts: {} }) + for (const [providerID, value] of Object.entries(store)) { + if (value && typeof value === "object" && !("accounts" in value)) { + // Legacy format - migrate + const info = value as unknown as Info + store[providerID] = { + accounts: { default: info }, + activeAccount: "default" + } + } + } + return store } - export async function set(key: string, info: Info) { + // Load auth data from file + async function load(): Promise { const file = Bun.file(filepath) - const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 }) + const data = await file.json().catch(() => ({})) + return await migrateIfNeeded(data) } - export async function remove(key: string) { + // Save auth data to file + async function save(store: AccountStore) { const file = Bun.file(filepath) - const data = await all() - delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + await Bun.write(file, JSON.stringify(store, null, 2), { mode: 0o600 }) } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c1..2fb1be82d42 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -16,7 +16,6 @@ type PluginAuth = NonNullable /** * Handle plugin-based authentication flow. - * Returns true if auth was handled, false if it should fall through to default handling. */ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { let index = 0 @@ -35,7 +34,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } const method = plugin.auth.methods[index] - // Handle prompts for all auth types await Bun.sleep(10) const inputs: Record = {} if (method.prompts) { @@ -83,7 +81,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await Auth.add(saveProvider, { type: "oauth", refresh, access, @@ -92,7 +90,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): }) } if ("key" in result) { - await Auth.set(saveProvider, { + await Auth.add(saveProvider, { type: "api", key: result.key, }) @@ -115,7 +113,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await Auth.add(saveProvider, { type: "oauth", refresh, access, @@ -124,7 +122,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): }) } if ("key" in result) { - await Auth.set(saveProvider, { + await Auth.add(saveProvider, { type: "api", key: result.key, }) @@ -145,7 +143,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { + await Auth.add(saveProvider, { type: "api", key: result.key, }) @@ -163,29 +161,48 @@ export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthListCommand) + .command(AuthUseCommand) + .demandCommand(), async handler() {}, }) export const AuthListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "list providers", + describe: "list providers and accounts", async handler() { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Auth.all()) + const results = await Auth.all() const database = await ModelsDev.get() - for (const [providerID, result] of results) { + // Group by provider + for (const [providerID, providerData] of Object.entries(results)) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + + // Show provider name + prompts.log.info(`${UI.Style.TEXT_BOLD}${name}`) + + // Show all accounts for this provider + if (providerData.accounts) { + for (const [accountId, info] of Object.entries(providerData.accounts)) { + const isActive = accountId === providerData.activeAccount + const isDisabled = "disabled" in info && info.disabled + const marker = isActive ? " ✓" : (isDisabled ? " (disabled)" : "") + const label = accountId === "default" ? "default" : accountId + prompts.log.info(` ${label}${marker} ${UI.Style.TEXT_DIM}(${info.type})`) + } + } } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${Object.keys(results).length} providers`) // Environment variables section const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -228,7 +245,12 @@ export const AuthLoginCommand = cmd({ async fn() { UI.empty() prompts.intro("Add credential") + + // Check if provider already has accounts - offer options + const existingProviders = await Auth.all() + if (args.url) { + // Well-known auth const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Bun.spawn({ @@ -242,7 +264,7 @@ export const AuthLoginCommand = cmd({ return } const token = await new Response(proc.stdout).text() - await Auth.set(args.url, { + await Auth.add(args.url, { type: "wellknown", key: wellknown.auth.env, token: token.trim(), @@ -251,6 +273,7 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } + await ModelsDev.refresh().catch(() => {}) const config = await Config.get() @@ -307,6 +330,63 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() + // Check if this provider already has accounts + const hasExistingAccounts = existingProviders[provider] && + Object.keys(existingProviders[provider].accounts || {}).length > 0 + + if (hasExistingAccounts) { + // Ask what to do: add another, switch, or manage + const action = await prompts.select({ + message: "This provider already has accounts. What would you like to do?", + options: [ + { label: "Add another account", value: "add" }, + { label: "Switch active account", value: "switch" }, + { label: "Manage accounts (enable/disable)", value: "manage" }, + ], + }) + if (prompts.isCancel(action)) throw new UI.CancelledError() + + if (action === "switch") { + const accounts = await Auth.list(provider) + const currentActive = await Auth.getActiveAccount(provider) + const selected = await prompts.select({ + message: "Select active account", + options: accounts.map(acc => ({ + label: acc === "default" ? "default" : acc, + value: acc, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + await Auth.use(provider, selected) + prompts.log.success(`Switched to ${selected}`) + prompts.outro("Done") + return + } + + if (action === "manage") { + const accounts = await Auth.list(provider) + const selected = await prompts.select({ + message: "Select account to toggle", + options: [ + ...accounts.map(acc => ({ + label: acc === "default" ? "default" : acc, + value: acc, + })), + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + const currentAccounts = await Auth.getAccounts(provider) + const isDisabled = currentAccounts[selected]?.disabled ?? false + + await Auth.setEnabled(provider, selected, isDisabled) + prompts.log.success(isDisabled ? "Account enabled" : "Account disabled") + prompts.outro("Done") + return + } + // If "add", continue to authentication + } + const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { const handled = await handlePluginAuth({ auth: plugin.auth }, provider) @@ -316,13 +396,12 @@ export const AuthLoginCommand = cmd({ if (provider === "other") { provider = await prompts.text({ message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + validate: (x) => (x && x.match(/^[0-9a-z-]+$/)) ? undefined : "a-z, 0-9 and hyphens only", }) if (prompts.isCancel(provider)) throw new UI.CancelledError() provider = provider.replace(/^@ai-sdk\//, "") if (prompts.isCancel(provider)) throw new UI.CancelledError() - // Check if a plugin provides auth for this custom provider const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) if (customPlugin && customPlugin.auth) { const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) @@ -363,11 +442,23 @@ export const AuthLoginCommand = cmd({ validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) if (prompts.isCancel(key)) throw new UI.CancelledError() - await Auth.set(provider, { + + // Ask for email (optional, for identification) + const email = await prompts.text({ + message: "Account name/email (optional, for identification)", + placeholder: "e.g., work, personal, user@gmail.com", + }) + + const info: Auth.Info = { type: "api", key, - }) - + } + + if (email && !prompts.isCancel(email)) { + (info as any).email = email.trim() + } + + await Auth.add(provider, info) prompts.outro("Done") }, }) @@ -379,22 +470,121 @@ export const AuthLogoutCommand = cmd({ describe: "log out from a configured provider", async handler() { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) + const credentials = await Auth.all() + const providers = Object.keys(credentials) + prompts.intro("Remove credential") - if (credentials.length === 0) { + if (providers.length === 0) { prompts.log.error("No credentials found") return } + const database = await ModelsDev.get() + + // Show provider selection with account count const providerID = await prompts.select({ message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), + options: providers.map(key => { + const accountCount = Object.keys(credentials[key].accounts || {}).length + return { + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + ` (${accountCount} account${accountCount !== 1 ? "s" : ""})`, + value: key, + } + }), }) if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + + // Show account selection + const accounts = await Auth.list(providerID) + if (accounts.length > 1) { + const accountToRemove = await prompts.select({ + message: "Select account to remove", + options: [ + { label: "All accounts", value: "all" }, + ...accounts.map(acc => ({ + label: acc === "default" ? "default" : acc, + value: acc, + })), + ], + }) + if (prompts.isCancel(accountToRemove)) throw new UI.CancelledError() + + await Auth.remove(providerID, accountToRemove) + } else { + await Auth.remove(providerID) + } + prompts.outro("Logout successful") }, }) + +export const AuthUseCommand = cmd({ + command: "use", + describe: "switch between accounts for a provider", + builder: (yargs) => + yargs + .positional("provider", { + describe: "provider id", + type: "string", + }) + .positional("account", { + describe: "account name or email", + type: "string", + }), + async handler(args) { + if (!args.provider || !args.account) { + // Interactive mode + const credentials = await Auth.all() + const providers = Object.keys(credentials) + + if (providers.length === 0) { + prompts.log.error("No providers found") + return + } + + const database = await ModelsDev.get() + + const providerID = await prompts.select({ + message: "Select provider", + options: providers.map(key => ({ + label: database[key]?.name || key, + value: key, + })), + }) + if (prompts.isCancel(providerID)) throw new UI.CancelledError() + + const accounts = await Auth.list(providerID) + if (accounts.length === 0) { + prompts.log.error("No accounts found for this provider") + return + } + + const account = await prompts.select({ + message: "Select account", + options: accounts.map(acc => ({ + label: acc === "default" ? "default" : acc, + value: acc, + })), + }) + if (prompts.isCancel(account)) throw new UI.CancelledError() + + try { + await Auth.use(providerID, account) + prompts.log.success(`Switched to ${account}`) + } catch (error) { + prompts.log.error(String(error)) + } + prompts.outro("Done") + return + } + + // Direct mode + try { + await Auth.use(args.provider, args.account) + prompts.log.success(`Switched to ${args.account} for ${args.provider}`) + } catch (error) { + prompts.log.error(String(error)) + } + prompts.outro("Done") + }, +}) From cdfb5aaea8c84d4510a873a68e3c5c1af19040c6 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 21:58:52 +0000 Subject: [PATCH 02/12] fix(auth): update provider/auth.ts to use new multi-account functions - Use Auth.add instead of Auth.set for OAuth/API callbacks - Auth.get now automatically returns active account or first available --- packages/opencode/src/provider/auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e6681ff0891..22258196831 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -93,7 +93,7 @@ export namespace ProviderAuth { if (result?.type === "success") { if ("key" in result) { - await Auth.set(input.providerID, { + await Auth.add(input.providerID, { type: "api", key: result.key, }) @@ -108,7 +108,7 @@ export namespace ProviderAuth { if (result.accountId) { info.accountId = result.accountId } - await Auth.set(input.providerID, info) + await Auth.add(input.providerID, info) } return } @@ -123,7 +123,7 @@ export namespace ProviderAuth { key: z.string(), }), async (input) => { - await Auth.set(input.providerID, { + await Auth.add(input.providerID, { type: "api", key: input.key, }) From c890861cece3f9e7f9352f7a42120b17d2c74433 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 21:59:19 +0000 Subject: [PATCH 03/12] feat(auth): add env var support for account selection - OPENCODE_ACCOUNT_ to select specific account - OPENCODE_ACCOUNT as general override - Example: OPENCODE_ACCOUNT_OPENAI=work --- packages/opencode/src/auth/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 5087b7c7440..39fa1049886 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -52,6 +52,9 @@ export namespace Auth { /** * Get credential for a provider. * Uses activeAccount if set, otherwise returns first available. + * Supports environment variable overrides: + * - OPENCODE_ACCOUNT_: e.g., OPENCODE_ACCOUNT_OPENAI=work + * - OPENCODE_ACCOUNT: general override for any provider */ export async function get(providerID: string): Promise { const store = await load() @@ -59,6 +62,19 @@ export namespace Auth { if (!provider || !provider.accounts) return undefined + // Check for environment variable overrides + const envVarName = `OPENCODE_ACCOUNT_${providerID.toUpperCase()}` + const envAccount = process.env[envVarName] || process.env["OPENCODE_ACCOUNT"] + + if (envAccount) { + // Use the specific account from env var + const account = provider.accounts[envAccount] + if (account && !account.disabled) { + return account + } + // If account not found or disabled, fall through to active account + } + // Use active account if set if (provider.activeAccount && provider.accounts[provider.activeAccount]) { return provider.accounts[provider.activeAccount] From eff41fcd47cfaf77963f79f9c956a3bcf2a17493 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 22:05:03 +0000 Subject: [PATCH 04/12] feat(auth): add withRetry for automatic account rotation on 429 errors - Add isRateLimitError() helper - Add withRetry() function that auto-switches accounts on rate-limit - Usage: Auth.withRetry('openai', async (info) => { ... }) --- packages/opencode/src/auth/index.ts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 39fa1049886..8422c4c77bc 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -264,6 +264,67 @@ export namespace Auth { return { account, info } } + /** + * Check if an error is a rate-limit error (429). + */ + export function isRateLimitError(error: unknown): boolean { + if (!error) return false + + const errorObj = error as any + const status = errorObj?.status || errorObj?.statusCode + + if (status === 429) return true + + // Check for common rate-limit messages + const message = errorObj?.message || String(error) + const lowerMessage = message.toLowerCase() + return lowerMessage.includes("rate limit") || + lowerMessage.includes("too many requests") || + lowerMessage.includes("rate_limit") || + lowerMessage.includes("429") + } + + /** + * Execute a function with automatic account rotation on rate-limit. + * Returns the result if successful, or throws if all accounts are exhausted. + */ + export async function withRetry( + providerID: string, + fn: (info: Info) => Promise, + maxRetries?: number + ): Promise { + const max = maxRetries ?? 10 + let lastError: unknown + + for (let i = 0; i < max; i++) { + const info = await get(providerID) + if (!info) { + throw new Error(`No credentials found for provider ${providerID}`) + } + + try { + return await fn(info) + } catch (error) { + lastError = error + + if (!isRateLimitError(error)) { + // Not a rate-limit error, throw immediately + throw error + } + + // Rate-limit error - try next account + console.log(`[auth] Rate limited on ${providerID}, switching account...`) + + const next = await getNextAccount(providerID) + if (!next) { + throw new Error(`Rate limited and no more accounts available for ${providerID}`) + } + } + } + + throw lastError + } + /** * Legacy compatibility: convert old format to new. */ From 452d220be251ef1c5d4679bcef5662bdd4291321 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 21:01:38 -0300 Subject: [PATCH 05/12] Ralph iteration 1: work in progress --- .ralph/ralph-history.json | 31 ++ .ralph/ralph-loop.state.json | 13 + .ralph/ralph-opencode.config.json | 20 ++ packages/opencode/src/auth/index.ts | 127 ++++--- packages/opencode/src/config/config.ts | 6 + packages/opencode/test/auth/auth.test.ts | 396 ++++++++++++++++++++++ prompt.md | 400 +++++++++++++++++++++++ 7 files changed, 942 insertions(+), 51 deletions(-) create mode 100644 .ralph/ralph-history.json create mode 100644 .ralph/ralph-loop.state.json create mode 100644 .ralph/ralph-opencode.config.json create mode 100644 packages/opencode/test/auth/auth.test.ts create mode 100644 prompt.md diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json new file mode 100644 index 00000000000..f27aa51898a --- /dev/null +++ b/.ralph/ralph-history.json @@ -0,0 +1,31 @@ +{ + "iterations": [ + { + "iteration": 1, + "startedAt": "2026-02-13T00:01:28.721Z", + "endedAt": "2026-02-13T00:01:38.534Z", + "durationMs": 4807, + "toolsUsed": {}, + "filesModified": [ + ".ralph/", + "packages/opencode/test/auth/", + "packages/console/app/public/email" + ], + "exitCode": 0, + "completionDetected": false, + "errors": [ + "ProviderModelNotFoundError: ProviderModelNotFoundError", + "\u001b[91m\u001b[1mError: \u001b[0mModel not found: minimax-coding-plan/MiniMax-M2.5." + ] + } + ], + "totalDurationMs": 4807, + "struggleIndicators": { + "repeatedErrors": { + "ProviderModelNotFoundError: ProviderModelNotFoundError": 1, + "\u001b[91m\u001b[1mError: \u001b[0mModel not found: minimax-coding-plan/MiniMax-M2.5.": 1 + }, + "noProgressIterations": 0, + "shortIterations": 1 + } +} \ No newline at end of file diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json new file mode 100644 index 00000000000..21b1942220f --- /dev/null +++ b/.ralph/ralph-loop.state.json @@ -0,0 +1,13 @@ +{ + "active": true, + "iteration": 1, + "minIterations": 1, + "maxIterations": 50, + "completionPromise": "COMPLETE", + "tasksMode": false, + "taskPromise": "READY_FOR_NEXT_TASK", + "prompt": "\n# Plano Completo: Multi-Auth para OpenCode\n\n## 1. Objetivo\n\nPermitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com:\n\n- troca rapida de conta ativa\n- selecao explicita por projeto/sessao quando necessario\n- compatibilidade total com formato legado de credenciais\n- zero regressao para usuarios com uma unica conta\n\n## 2. Problema Atual\n\nHoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider.\nIsso impede cenarios comuns:\n\n- separar conta pessoal e conta corporativa no mesmo provider\n- alternar billing sem relogar\n- padronizar por projeto (repo A usa `work`, repo B usa `personal`)\n\n## 3. Escopo\n\n### Em escopo\n\n- novo modelo de dados para auth com contas nomeadas\n- comandos CLI para gerenciar contas\n- interface no TUI/app para trocar conta ativa sem depender apenas de CLI\n- selecao de conta no runtime com regras de precedencia\n- migracao compativel com auth legado\n- testes unitarios e de integracao de fluxo principal\n- documentacao de uso e troubleshooting\n\n### Fora de escopo (fase inicial)\n\n- sincronizacao cloud de multiplas contas\n- politicas de equipe via servidor remoto (pode vir na fase 2)\n\n## 4. Requisitos Funcionais\n\n1. Cadastrar varias contas para o mesmo provider.\n2. Definir conta default por provider.\n3. Trocar conta default sem novo login.\n4. Selecionar conta por projeto/sessao via config/env.\n5. Listar contas e visualizar qual esta ativa.\n6. Remover conta especifica sem apagar as demais.\n7. Remover provider inteiro quando desejado.\n8. Ler auth legado sem erro.\n9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider.\n\n## 5. Requisitos Nao Funcionais\n\n1. Backward compatibility total.\n2. Armazenamento local com permissao `0600`, igual ao comportamento atual.\n3. Sem uso de `any`.\n4. Sem degradar tempo de inicializacao de forma perceptivel.\n5. Cobertura de testes para paths criticos (legacy + novo).\n6. Build e typecheck devem passar ao final da implementacao.\n\n## 6. Arquitetura Proposta\n\n### 6.1 Modelo de dados (auth.json v2)\n\nFormato proposto:\n\n```json\n{\n \"openai\": {\n \"default\": \"work\",\n \"accounts\": {\n \"work\": {\n \"type\": \"oauth\",\n \"access\": \"...\",\n \"refresh\": \"...\",\n \"expires\": 1769999999,\n \"accountId\": \"org_work\"\n },\n \"personal\": {\n \"type\": \"api\",\n \"key\": \"sk-...\"\n }\n }\n },\n \"anthropic\": {\n \"default\": \"default\",\n \"accounts\": {\n \"default\": {\n \"type\": \"api\",\n \"key\": \"...\"\n }\n }\n }\n}\n```\n\n### 6.2 Compatibilidade com legado\n\nFormato legado atual:\n\n```json\n{\n \"openai\": { \"type\": \"api\", \"key\": \"...\" }\n}\n```\n\nRegra:\n\n- leitura: se valor de provider estiver no formato legado, tratar em memoria como:\n - `default = \"default\"`\n - `accounts.default = `\n- escrita: qualquer mutacao salva no formato v2\n\n### 6.3 Schema em codigo\n\nArquivo principal: `packages/opencode/src/auth/index.ts`\n\nAdicionar:\n\n- `Auth.Accounts` (objeto com `default` e `accounts`)\n- `Auth.Storage` (record provider -> Accounts)\n- parser que aceite `Info | Accounts` no input e normalize para `Accounts`\n\n## 7. API Interna de Auth\n\nEvolucao da namespace `Auth`:\n\n1. `all(): Promise>`\n2. `list(providerID?: string): Promise | Record>>`\n3. `get(providerID: string, account?: string): Promise`\n4. `set(providerID: string, info: Info, account?: string): Promise`\n5. `use(providerID: string, account: string): Promise`\n6. `remove(providerID: string, account?: string): Promise`\n7. `default(providerID: string): Promise`\n\nRegras:\n\n- `account` default para `\"default\"` no `set` quando provider nao existir\n- `set` em conta nova nao deve alterar default automaticamente se ja existir default\n- `remove(provider, account)`:\n - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica)\n - se remover ultima conta, remover provider inteiro\n\n## 8. Design CLI\n\nArquivo: `packages/opencode/src/cli/cmd/auth.ts`\n\n### 8.1 `opencode auth login`\n\nFluxo:\n\n1. selecionar provider (igual hoje)\n2. executar fluxo de auth (oauth/api/plugin)\n3. perguntar alias da conta:\n - default sugestao: `default`\n - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$`\n4. opcional: \"usar como conta padrao agora?\" (sim/nao)\n\n### 8.2 `opencode auth list`\n\nExibir por provider:\n\n- provider name\n- tipo de cada conta (`api`, `oauth`, `wellknown`)\n- marcador da conta ativa (`*`)\n- metadados relevantes quando existirem (`accountId`)\n\nExemplo:\n\n```txt\nOpenAI\n * work oauth accountId=org_work\n personal api\n```\n\n### 8.3 `opencode auth use`\n\nNovo comando:\n\n```bash\nopencode auth use\nopencode auth use openai personal\n```\n\nCom argumento opcional para modo nao interativo.\nSem argumentos, abre prompt provider -> conta.\n\n### 8.4 `opencode auth logout`\n\nAjustar para permitir:\n\n- remover conta especifica\n- remover provider inteiro (acao explicita)\n\nFluxo recomendado:\n\n1. selecionar provider\n2. selecionar conta ou \"all accounts\"\n3. confirmar quando for \"all accounts\"\n\n### 8.5 Interface obrigatoria para troca de conta (TUI/app)\n\nObjetivo: permitir troca de conta ativa sem sair da interface principal.\n\nRequisitos minimos:\n\n1. Expor acao no TUI (slash command ou dialog de provider) para \"Switch account\".\n2. Listar contas por provider com marcador da conta ativa.\n3. Permitir trocar conta com confirmacao visual imediata.\n4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI.\n5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo).\n\n## 9. Resolucao de Conta no Runtime\n\nArquivos alvo:\n\n- `packages/opencode/src/provider/auth.ts`\n- `packages/opencode/src/config/config.ts`\n- pontos de chamada que leem credencial para provider\n\n### 9.1 Precedencia de resolucao\n\n1. override explicito da chamada (quando existir)\n2. config de projeto (`opencode.json`) para conta do provider\n3. env var dedicada (opcional na fase 1, recomendado fase 1.5)\n4. conta default do provider\n\n### 9.2 Chave de configuracao sugerida\n\nNo `opencode.json`:\n\n```json\n{\n \"auth\": {\n \"account\": {\n \"openai\": \"work\",\n \"anthropic\": \"personal\"\n }\n }\n}\n```\n\nSe essa chave for adicionada na fase 1, deve entrar no schema de config com docs.\nSe ficar para fase 2, manter apenas default global via `auth use`.\n\n## 10. OAuth e Plugins\n\nNos fluxos OAuth/plugin:\n\n- continuar salvando `accountId` quando o provider retornar esse campo\n- associar resultado ao alias escolhido no CLI\n- manter comportamento atual para providers que retornam `provider` custom no callback\n\n## 11. Migracao\n\n### 11.1 Estrategia\n\n- migracao lazy na leitura\n- persistencia no novo formato na primeira escrita\n- sem comando manual obrigatorio\n\n### 11.2 Integridade\n\n- parse robusto por item\n- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira\n- escrita atomica via `Bun.write`\n\n### 11.3 Rollback\n\nNao ha rollback automatico de arquivo.\nPara seguranca operacional, documentar backup manual:\n\n```bash\ncp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak\n```\n\n## 12. Plano de Testes\n\n### 12.1 Unitarios (Auth core)\n\nArquivo sugerido: `packages/opencode/test/auth/auth.test.ts`\n\nCenarios:\n\n1. ler formato legado e normalizar corretamente\n2. `set` cria provider e conta default\n3. `set` adiciona segunda conta sem trocar default\n4. `use` troca default para conta existente\n5. `remove` conta nao default preserva default\n6. `remove` conta default promove outra\n7. `remove` ultima conta apaga provider\n8. parser ignora entradas invalidas\n\n### 12.2 CLI integration\n\nArquivos em `packages/opencode/test/...`\n\n1. `auth list` mostra marcador de default\n2. `auth use` altera conta ativa\n3. `auth logout` remove somente conta escolhida\n\n### 12.3 Runtime integration\n\n1. provider resolve conta por default\n2. provider resolve conta por override de config\n3. fallback legado continua funcional\n\n## 13. Riscos e Mitigacoes\n\n1. Risco: quebrar leitura de auth legado.\n Mitigacao: parser dual + testes de fixture legado.\n\n2. Risco: confusao de UX com muitas opcoes.\n Mitigacao: defaults fortes e fluxo interativo curto.\n\n3. Risco: conflito de nomes de conta.\n Mitigacao: validacao e confirmacao de overwrite.\n\n4. Risco: providers com oauth diferente por tenant.\n Mitigacao: preservar `accountId` e expor no `auth list`.\n\n## 14. Fases de Entrega\n\n### Fase 1 (core)\n\n- schema/auth storage v2\n- compat legado\n- API interna (`get/set/use/remove/list`)\n- testes unitarios de auth\n\n### Fase 2 (CLI)\n\n- `auth login` com alias\n- `auth list` com contas\n- `auth use`\n- `auth logout` granular\n- interface no TUI/app para troca de conta ativa\n- testes de CLI\n\n### Fase 3 (runtime/config)\n\n- resolucao por conta ativa\n- override por config/env (se aprovado no escopo)\n- testes de integracao de provider\n\n### Fase 4 (docs e hardening)\n\n- docs de comandos novos\n- troubleshooting multi-auth\n- validacao final com cenarios work/personal\n\n## 15. Definicao de Pronto (DoD)\n\n1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente.\n2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar.\n3. Sessao usa a conta esperada conforme regra de precedencia.\n4. Usuarios legados nao precisam fazer nenhuma acao manual.\n5. Suite de testes adicionada e passando nos modulos afetados.\n6. Docs publicadas com exemplos reais.\n7. Existe interface no TUI/app para trocar conta ativa por provider.\n8. `typecheck` e `build` passam sem erros nos pacotes afetados.\n\n## 16. Checklist de Implementacao\n\n1. Atualizar `Auth` schema e normalizacao legado.\n2. Implementar API interna multi-account.\n3. Atualizar fluxos de `auth login/list/logout`.\n4. Adicionar `auth use`.\n5. Integrar resolucao de conta no runtime.\n6. Adicionar/atualizar testes.\n7. Atualizar docs PT-BR + EN.\n8. Validar manualmente com duas contas no mesmo provider.\n9. Implementar interface no TUI/app para troca de conta.\n10. Garantir `build` e `typecheck` verdes antes de merge.\n\n## 17. Exemplos de Uso Final\n\n```bash\n# login da conta corporativa\nopencode auth login\n# alias: work\n\n# login da conta pessoal\nopencode auth login\n# alias: personal\n\n# ver estado\nopencode auth list\n\n# trocar conta ativa\nopencode auth use openai personal\n\n# remover so a conta pessoal\nopencode auth logout\n```\n\n## 18. Open Questions\n\n1. Queremos incluir override por config ja na primeira entrega?\n2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal?\n3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`?\n", + "startedAt": "2026-02-13T00:01:23.702Z", + "model": "minimax-coding-plan/MiniMax-M2.5", + "agent": "opencode" +} \ No newline at end of file diff --git a/.ralph/ralph-opencode.config.json b/.ralph/ralph-opencode.config.json new file mode 100644 index 00000000000..f23c3f91c86 --- /dev/null +++ b/.ralph/ralph-opencode.config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": "allow", + "edit": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "bash": "allow", + "task": "allow", + "webfetch": "allow", + "websearch": "allow", + "codesearch": "allow", + "todowrite": "allow", + "todoread": "allow", + "question": "allow", + "lsp": "allow", + "external_directory": "allow" + } +} \ No newline at end of file diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 8422c4c77bc..70457c8dbd3 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -52,20 +52,43 @@ export namespace Auth { /** * Get credential for a provider. * Uses activeAccount if set, otherwise returns first available. - * Supports environment variable overrides: - * - OPENCODE_ACCOUNT_: e.g., OPENCODE_ACCOUNT_OPENAI=work - * - OPENCODE_ACCOUNT: general override for any provider + * Precedence order: + * 1. Explicit account parameter (if provided) + * 2. Config project override (auth.account in opencode.json) + * 3. Environment variable overrides: + * - OPENCODE_ACCOUNT_: e.g., OPENCODE_ACCOUNT_OPENAI=work + * - OPENCODE_ACCOUNT: general override for any provider + * 4. Active account for the provider + * 5. First available non-disabled account */ - export async function get(providerID: string): Promise { + export async function get(providerID: string, explicitAccount?: string): Promise { const store = await load() const provider = store[providerID] - + if (!provider || !provider.accounts) return undefined - - // Check for environment variable overrides + + // 1. Use explicit account if provided + if (explicitAccount && provider.accounts[explicitAccount] && !provider.accounts[explicitAccount].disabled) { + return provider.accounts[explicitAccount] + } + + // 2. Check for config project override (lazy import to avoid circular dependency) + let configAccount: string | undefined + try { + const { Config } = await import("../config/config") + const config = await Config.get() + configAccount = config.auth?.[providerID] + if (configAccount && provider.accounts[configAccount] && !provider.accounts[configAccount].disabled) { + return provider.accounts[configAccount] + } + } catch { + // Config may not be available in all contexts + } + + // 3. Check for environment variable overrides const envVarName = `OPENCODE_ACCOUNT_${providerID.toUpperCase()}` const envAccount = process.env[envVarName] || process.env["OPENCODE_ACCOUNT"] - + if (envAccount) { // Use the specific account from env var const account = provider.accounts[envAccount] @@ -74,17 +97,17 @@ export namespace Auth { } // If account not found or disabled, fall through to active account } - - // Use active account if set + + // 4. Use active account if set if (provider.activeAccount && provider.accounts[provider.activeAccount]) { return provider.accounts[provider.activeAccount] } - - // Otherwise, find first non-disabled account + + // 5. Otherwise, find first non-disabled account for (const [id, info] of Object.entries(provider.accounts)) { if (!info.disabled) return info } - + return undefined } @@ -111,11 +134,11 @@ export namespace Auth { */ export async function add(providerID: string, info: Info): Promise { const store = await load() - + if (!store[providerID]) { store[providerID] = { accounts: {} } } - + // Generate account ID from email if available, otherwise use timestamp let accountId = "default" if ("email" in info && info.email) { @@ -126,14 +149,14 @@ export namespace Auth { } else { accountId = `key-${Date.now()}` } - + store[providerID].accounts[accountId] = info - + // If this is the first account, set as active if (!store[providerID].activeAccount) { store[providerID].activeAccount = accountId } - + await save(store) return accountId } @@ -166,9 +189,9 @@ export namespace Auth { export async function remove(providerID: string, account?: string) { const store = await load() const provider = store[providerID] - + if (!provider) return - + if (!account) { // Remove all accounts for this provider delete store[providerID] @@ -176,14 +199,14 @@ export namespace Auth { delete store[providerID] } else { delete provider.accounts[account] - + // If we removed the active account, switch to another if (provider.activeAccount === account) { const remaining = Object.keys(provider.accounts) provider.activeAccount = remaining[0] ?? undefined } } - + await save(store) } @@ -210,11 +233,11 @@ export namespace Auth { export async function use(providerID: string, account: string) { const store = await load() const provider = store[providerID] - + if (!provider || !provider.accounts[account]) { throw new Error(`Account ${account} not found for provider ${providerID}`) } - + provider.activeAccount = account await save(store) } @@ -225,42 +248,42 @@ export namespace Auth { export async function setEnabled(providerID: string, account: string, enabled: boolean) { const store = await load() const provider = store[providerID] - + if (!provider || !provider.accounts[account]) return - + provider.accounts[account].disabled = !enabled - + // If we disabled the active account, switch to another if (!enabled && provider.activeAccount === account) { - const remaining = Object.keys(provider.accounts).filter(a => !provider.accounts[a].disabled) + const remaining = Object.keys(provider.accounts).filter((a) => !provider.accounts[a].disabled) provider.activeAccount = remaining[0] ?? undefined } - + await save(store) } /** * Get next available account (for auto-rotation on rate-limit). */ - export async function getNextAccount(providerID: string): Promise<{ account: string, info: Info } | undefined> { + export async function getNextAccount(providerID: string): Promise<{ account: string; info: Info } | undefined> { const store = await load() const provider = store[providerID] - + if (!provider || !provider.accounts) return undefined - + const accounts = Object.entries(provider.accounts).filter(([_, info]) => !info.disabled) - + if (accounts.length === 0) return undefined - + // Simple round-robin: switch to next account const currentActive = provider.activeAccount const currentIndex = accounts.findIndex(([id]) => id === currentActive) const nextIndex = (currentIndex + 1) % accounts.length - + const [account, info] = accounts[nextIndex] provider.activeAccount = account await save(store) - + return { account, info } } @@ -269,19 +292,21 @@ export namespace Auth { */ export function isRateLimitError(error: unknown): boolean { if (!error) return false - + const errorObj = error as any const status = errorObj?.status || errorObj?.statusCode - + if (status === 429) return true - + // Check for common rate-limit messages const message = errorObj?.message || String(error) const lowerMessage = message.toLowerCase() - return lowerMessage.includes("rate limit") || - lowerMessage.includes("too many requests") || - lowerMessage.includes("rate_limit") || - lowerMessage.includes("429") + return ( + lowerMessage.includes("rate limit") || + lowerMessage.includes("too many requests") || + lowerMessage.includes("rate_limit") || + lowerMessage.includes("429") + ) } /** @@ -291,37 +316,37 @@ export namespace Auth { export async function withRetry( providerID: string, fn: (info: Info) => Promise, - maxRetries?: number + maxRetries?: number, ): Promise { const max = maxRetries ?? 10 let lastError: unknown - + for (let i = 0; i < max; i++) { const info = await get(providerID) if (!info) { throw new Error(`No credentials found for provider ${providerID}`) } - + try { return await fn(info) } catch (error) { lastError = error - + if (!isRateLimitError(error)) { // Not a rate-limit error, throw immediately throw error } - + // Rate-limit error - try next account console.log(`[auth] Rate limited on ${providerID}, switching account...`) - + const next = await getNextAccount(providerID) if (!next) { throw new Error(`Rate limited and no more accounts available for ${providerID}`) } } } - + throw lastError } @@ -336,7 +361,7 @@ export namespace Auth { const info = value as unknown as Info store[providerID] = { accounts: { default: info }, - activeAccount: "default" + activeAccount: "default", } } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8f0f583ea3d..1a299632643 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1084,6 +1084,12 @@ export namespace Config { .record(z.string(), Provider) .optional() .describe("Custom provider configurations and model overrides"), + auth: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Account to use per provider. Use provider ID as key and account name as value (e.g., { "openai": "work", "anthropic": "personal" })', + ), mcp: z .record( z.string(), diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts new file mode 100644 index 00000000000..f862da7f229 --- /dev/null +++ b/packages/opencode/test/auth/auth.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { Auth } from "../../src/auth" +import type { Auth as AuthType } from "../../src/auth" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" +import path from "path" +import fs from "fs/promises" + +function asApiAuth(info: AuthType.Info | undefined): { key: string } | undefined { + return info as any +} + +describe("auth multi-account", () => { + let testAuthPath: string + + beforeEach(async () => { + testAuthPath = path.join(Global.Path.data, "auth.json") + await fs.rm(testAuthPath, { force: true }).catch(() => {}) + }) + + afterEach(async () => { + await fs.rm(testAuthPath, { force: true }).catch(() => {}) + }) + + test("add creates first account as active", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const accountId = await Auth.add("openai", { + type: "api", + key: "sk-test-key", + }) + + const accounts = await Auth.list("openai") + expect(accounts).toContain(accountId) + expect(await Auth.getActiveAccount("openai")).toBe(accountId) + }, + }) + }) + + test("add second account does not change active account", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + const firstActive = await Auth.getActiveAccount("openai") + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + expect(await Auth.getActiveAccount("openai")).toBe(firstActive) + }, + }) + }) + + test("use changes active account", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + await Auth.use("openai", "work") + expect(await Auth.getActiveAccount("openai")).toBe("work") + + const creds = asApiAuth(await Auth.get("openai")) + expect(creds?.key).toBe("sk-key-2") + }, + }) + }) + + test("remove non-active account preserves active", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + await Auth.remove("openai", "work") + expect(await Auth.getActiveAccount("openai")).toBe("default") + }, + }) + }) + + test("remove active account promotes another", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + await Auth.use("openai", "work") + await Auth.remove("openai", "work") + + const active = await Auth.getActiveAccount("openai") + expect(active).toBeDefined() + expect(active).toBe("default") + }, + }) + }) + + test("remove last account removes provider", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.remove("openai", "default") + + const accounts = await Auth.list("openai") + expect(accounts).toHaveLength(0) + }, + }) + }) + + test("get returns credentials for active account", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + const creds = asApiAuth(await Auth.get("openai")) + expect(creds?.key).toBe("sk-key-1") + }, + }) + }) + + test("get accepts explicit account parameter", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + await Auth.use("openai", "default") + + const creds = asApiAuth(await Auth.get("openai", "work")) + expect(creds?.key).toBe("sk-key-2") + }, + }) + }) + + test("setEnabled can disable and enable accounts", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + await Auth.setEnabled("openai", "work", false) + + const creds = asApiAuth(await Auth.get("openai")) + expect(creds?.key).toBe("sk-key-1") + + await Auth.setEnabled("openai", "work", true) + await Auth.use("openai", "work") + + const creds2 = asApiAuth(await Auth.get("openai")) + expect(creds2?.key).toBe("sk-key-2") + }, + }) + }) + + test("getAccounts returns all accounts for provider", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "default", + ) + + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-2", + }, + "work", + ) + + const accounts = await Auth.getAccounts("openai") + expect(Object.keys(accounts)).toHaveLength(2) + }, + }) + }) + + test("set with account parameter stores credentials under that account", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-key-1", + }, + "work", + ) + + const creds = asApiAuth(await Auth.get("openai", "work")) + expect(creds?.key).toBe("sk-key-1") + expect(await Auth.getActiveAccount("openai")).toBe("work") + }, + }) + }) + + test("all returns all providers and accounts", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Auth.set( + "openai", + { + type: "api", + key: "sk-openai", + }, + "default", + ) + + await Auth.set( + "anthropic", + { + type: "api", + key: "sk-anthropic", + }, + "default", + ) + + const all = await Auth.all() + expect(all.openai).toBeDefined() + expect(all.anthropic).toBeDefined() + expect(Object.keys(all.openai.accounts)).toHaveLength(1) + expect(Object.keys(all.anthropic.accounts)).toHaveLength(1) + }, + }) + }) +}) + +describe("auth legacy migration", () => { + let testAuthPath: string + + beforeEach(async () => { + testAuthPath = path.join(Global.Path.data, "auth.json") + await fs.rm(testAuthPath, { force: true }).catch(() => {}) + }) + + afterEach(async () => { + await fs.rm(testAuthPath, { force: true }).catch(() => {}) + }) + + test("migrates legacy format on read", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write( + testAuthPath, + JSON.stringify({ + openai: { + type: "api", + key: "sk-legacy-key", + }, + }), + ) + + const creds = asApiAuth(await Auth.get("openai")) + expect(creds?.key).toBe("sk-legacy-key") + + const all = await Auth.all() + expect(all.openai.accounts.default).toBeDefined() + expect(all.openai.activeAccount).toBe("default") + }, + }) + }) +}) diff --git a/prompt.md b/prompt.md new file mode 100644 index 00000000000..108d2be04d4 --- /dev/null +++ b/prompt.md @@ -0,0 +1,400 @@ + +# Plano Completo: Multi-Auth para OpenCode + +## 1. Objetivo + +Permitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com: + +- troca rapida de conta ativa +- selecao explicita por projeto/sessao quando necessario +- compatibilidade total com formato legado de credenciais +- zero regressao para usuarios com uma unica conta + +## 2. Problema Atual + +Hoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider. +Isso impede cenarios comuns: + +- separar conta pessoal e conta corporativa no mesmo provider +- alternar billing sem relogar +- padronizar por projeto (repo A usa `work`, repo B usa `personal`) + +## 3. Escopo + +### Em escopo + +- novo modelo de dados para auth com contas nomeadas +- comandos CLI para gerenciar contas +- interface no TUI/app para trocar conta ativa sem depender apenas de CLI +- selecao de conta no runtime com regras de precedencia +- migracao compativel com auth legado +- testes unitarios e de integracao de fluxo principal +- documentacao de uso e troubleshooting + +### Fora de escopo (fase inicial) + +- sincronizacao cloud de multiplas contas +- politicas de equipe via servidor remoto (pode vir na fase 2) + +## 4. Requisitos Funcionais + +1. Cadastrar varias contas para o mesmo provider. +2. Definir conta default por provider. +3. Trocar conta default sem novo login. +4. Selecionar conta por projeto/sessao via config/env. +5. Listar contas e visualizar qual esta ativa. +6. Remover conta especifica sem apagar as demais. +7. Remover provider inteiro quando desejado. +8. Ler auth legado sem erro. +9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider. + +## 5. Requisitos Nao Funcionais + +1. Backward compatibility total. +2. Armazenamento local com permissao `0600`, igual ao comportamento atual. +3. Sem uso de `any`. +4. Sem degradar tempo de inicializacao de forma perceptivel. +5. Cobertura de testes para paths criticos (legacy + novo). +6. Build e typecheck devem passar ao final da implementacao. + +## 6. Arquitetura Proposta + +### 6.1 Modelo de dados (auth.json v2) + +Formato proposto: + +```json +{ + "openai": { + "default": "work", + "accounts": { + "work": { + "type": "oauth", + "access": "...", + "refresh": "...", + "expires": 1769999999, + "accountId": "org_work" + }, + "personal": { + "type": "api", + "key": "sk-..." + } + } + }, + "anthropic": { + "default": "default", + "accounts": { + "default": { + "type": "api", + "key": "..." + } + } + } +} +``` + +### 6.2 Compatibilidade com legado + +Formato legado atual: + +```json +{ + "openai": { "type": "api", "key": "..." } +} +``` + +Regra: + +- leitura: se valor de provider estiver no formato legado, tratar em memoria como: + - `default = "default"` + - `accounts.default = ` +- escrita: qualquer mutacao salva no formato v2 + +### 6.3 Schema em codigo + +Arquivo principal: `packages/opencode/src/auth/index.ts` + +Adicionar: + +- `Auth.Accounts` (objeto com `default` e `accounts`) +- `Auth.Storage` (record provider -> Accounts) +- parser que aceite `Info | Accounts` no input e normalize para `Accounts` + +## 7. API Interna de Auth + +Evolucao da namespace `Auth`: + +1. `all(): Promise>` +2. `list(providerID?: string): Promise | Record>>` +3. `get(providerID: string, account?: string): Promise` +4. `set(providerID: string, info: Info, account?: string): Promise` +5. `use(providerID: string, account: string): Promise` +6. `remove(providerID: string, account?: string): Promise` +7. `default(providerID: string): Promise` + +Regras: + +- `account` default para `"default"` no `set` quando provider nao existir +- `set` em conta nova nao deve alterar default automaticamente se ja existir default +- `remove(provider, account)`: + - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica) + - se remover ultima conta, remover provider inteiro + +## 8. Design CLI + +Arquivo: `packages/opencode/src/cli/cmd/auth.ts` + +### 8.1 `opencode auth login` + +Fluxo: + +1. selecionar provider (igual hoje) +2. executar fluxo de auth (oauth/api/plugin) +3. perguntar alias da conta: + - default sugestao: `default` + - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$` +4. opcional: "usar como conta padrao agora?" (sim/nao) + +### 8.2 `opencode auth list` + +Exibir por provider: + +- provider name +- tipo de cada conta (`api`, `oauth`, `wellknown`) +- marcador da conta ativa (`*`) +- metadados relevantes quando existirem (`accountId`) + +Exemplo: + +```txt +OpenAI + * work oauth accountId=org_work + personal api +``` + +### 8.3 `opencode auth use` + +Novo comando: + +```bash +opencode auth use +opencode auth use openai personal +``` + +Com argumento opcional para modo nao interativo. +Sem argumentos, abre prompt provider -> conta. + +### 8.4 `opencode auth logout` + +Ajustar para permitir: + +- remover conta especifica +- remover provider inteiro (acao explicita) + +Fluxo recomendado: + +1. selecionar provider +2. selecionar conta ou "all accounts" +3. confirmar quando for "all accounts" + +### 8.5 Interface obrigatoria para troca de conta (TUI/app) + +Objetivo: permitir troca de conta ativa sem sair da interface principal. + +Requisitos minimos: + +1. Expor acao no TUI (slash command ou dialog de provider) para "Switch account". +2. Listar contas por provider com marcador da conta ativa. +3. Permitir trocar conta com confirmacao visual imediata. +4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI. +5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo). + +## 9. Resolucao de Conta no Runtime + +Arquivos alvo: + +- `packages/opencode/src/provider/auth.ts` +- `packages/opencode/src/config/config.ts` +- pontos de chamada que leem credencial para provider + +### 9.1 Precedencia de resolucao + +1. override explicito da chamada (quando existir) +2. config de projeto (`opencode.json`) para conta do provider +3. env var dedicada (opcional na fase 1, recomendado fase 1.5) +4. conta default do provider + +### 9.2 Chave de configuracao sugerida + +No `opencode.json`: + +```json +{ + "auth": { + "account": { + "openai": "work", + "anthropic": "personal" + } + } +} +``` + +Se essa chave for adicionada na fase 1, deve entrar no schema de config com docs. +Se ficar para fase 2, manter apenas default global via `auth use`. + +## 10. OAuth e Plugins + +Nos fluxos OAuth/plugin: + +- continuar salvando `accountId` quando o provider retornar esse campo +- associar resultado ao alias escolhido no CLI +- manter comportamento atual para providers que retornam `provider` custom no callback + +## 11. Migracao + +### 11.1 Estrategia + +- migracao lazy na leitura +- persistencia no novo formato na primeira escrita +- sem comando manual obrigatorio + +### 11.2 Integridade + +- parse robusto por item +- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira +- escrita atomica via `Bun.write` + +### 11.3 Rollback + +Nao ha rollback automatico de arquivo. +Para seguranca operacional, documentar backup manual: + +```bash +cp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak +``` + +## 12. Plano de Testes + +### 12.1 Unitarios (Auth core) + +Arquivo sugerido: `packages/opencode/test/auth/auth.test.ts` + +Cenarios: + +1. ler formato legado e normalizar corretamente +2. `set` cria provider e conta default +3. `set` adiciona segunda conta sem trocar default +4. `use` troca default para conta existente +5. `remove` conta nao default preserva default +6. `remove` conta default promove outra +7. `remove` ultima conta apaga provider +8. parser ignora entradas invalidas + +### 12.2 CLI integration + +Arquivos em `packages/opencode/test/...` + +1. `auth list` mostra marcador de default +2. `auth use` altera conta ativa +3. `auth logout` remove somente conta escolhida + +### 12.3 Runtime integration + +1. provider resolve conta por default +2. provider resolve conta por override de config +3. fallback legado continua funcional + +## 13. Riscos e Mitigacoes + +1. Risco: quebrar leitura de auth legado. + Mitigacao: parser dual + testes de fixture legado. + +2. Risco: confusao de UX com muitas opcoes. + Mitigacao: defaults fortes e fluxo interativo curto. + +3. Risco: conflito de nomes de conta. + Mitigacao: validacao e confirmacao de overwrite. + +4. Risco: providers com oauth diferente por tenant. + Mitigacao: preservar `accountId` e expor no `auth list`. + +## 14. Fases de Entrega + +### Fase 1 (core) + +- schema/auth storage v2 +- compat legado +- API interna (`get/set/use/remove/list`) +- testes unitarios de auth + +### Fase 2 (CLI) + +- `auth login` com alias +- `auth list` com contas +- `auth use` +- `auth logout` granular +- interface no TUI/app para troca de conta ativa +- testes de CLI + +### Fase 3 (runtime/config) + +- resolucao por conta ativa +- override por config/env (se aprovado no escopo) +- testes de integracao de provider + +### Fase 4 (docs e hardening) + +- docs de comandos novos +- troubleshooting multi-auth +- validacao final com cenarios work/personal + +## 15. Definicao de Pronto (DoD) + +1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente. +2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar. +3. Sessao usa a conta esperada conforme regra de precedencia. +4. Usuarios legados nao precisam fazer nenhuma acao manual. +5. Suite de testes adicionada e passando nos modulos afetados. +6. Docs publicadas com exemplos reais. +7. Existe interface no TUI/app para trocar conta ativa por provider. +8. `typecheck` e `build` passam sem erros nos pacotes afetados. + +## 16. Checklist de Implementacao + +1. Atualizar `Auth` schema e normalizacao legado. +2. Implementar API interna multi-account. +3. Atualizar fluxos de `auth login/list/logout`. +4. Adicionar `auth use`. +5. Integrar resolucao de conta no runtime. +6. Adicionar/atualizar testes. +7. Atualizar docs PT-BR + EN. +8. Validar manualmente com duas contas no mesmo provider. +9. Implementar interface no TUI/app para troca de conta. +10. Garantir `build` e `typecheck` verdes antes de merge. + +## 17. Exemplos de Uso Final + +```bash +# login da conta corporativa +opencode auth login +# alias: work + +# login da conta pessoal +opencode auth login +# alias: personal + +# ver estado +opencode auth list + +# trocar conta ativa +opencode auth use openai personal + +# remover so a conta pessoal +opencode auth logout +``` + +## 18. Open Questions + +1. Queremos incluir override por config ja na primeira entrega? +2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal? +3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`? From f713781e36c31690c4eea8d4dc2845df8919db5f Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 21:02:05 -0300 Subject: [PATCH 06/12] Ralph iteration 1: work in progress --- .ralph/ralph-history.json | 10 ++++------ .ralph/ralph-loop.state.json | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json index f27aa51898a..4c40196ce4d 100644 --- a/.ralph/ralph-history.json +++ b/.ralph/ralph-history.json @@ -2,13 +2,11 @@ "iterations": [ { "iteration": 1, - "startedAt": "2026-02-13T00:01:28.721Z", - "endedAt": "2026-02-13T00:01:38.534Z", - "durationMs": 4807, + "startedAt": "2026-02-13T00:01:58.908Z", + "endedAt": "2026-02-13T00:02:05.853Z", + "durationMs": 1943, "toolsUsed": {}, "filesModified": [ - ".ralph/", - "packages/opencode/test/auth/", "packages/console/app/public/email" ], "exitCode": 0, @@ -19,7 +17,7 @@ ] } ], - "totalDurationMs": 4807, + "totalDurationMs": 1943, "struggleIndicators": { "repeatedErrors": { "ProviderModelNotFoundError: ProviderModelNotFoundError": 1, diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json index 21b1942220f..c6471063118 100644 --- a/.ralph/ralph-loop.state.json +++ b/.ralph/ralph-loop.state.json @@ -7,7 +7,7 @@ "tasksMode": false, "taskPromise": "READY_FOR_NEXT_TASK", "prompt": "\n# Plano Completo: Multi-Auth para OpenCode\n\n## 1. Objetivo\n\nPermitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com:\n\n- troca rapida de conta ativa\n- selecao explicita por projeto/sessao quando necessario\n- compatibilidade total com formato legado de credenciais\n- zero regressao para usuarios com uma unica conta\n\n## 2. Problema Atual\n\nHoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider.\nIsso impede cenarios comuns:\n\n- separar conta pessoal e conta corporativa no mesmo provider\n- alternar billing sem relogar\n- padronizar por projeto (repo A usa `work`, repo B usa `personal`)\n\n## 3. Escopo\n\n### Em escopo\n\n- novo modelo de dados para auth com contas nomeadas\n- comandos CLI para gerenciar contas\n- interface no TUI/app para trocar conta ativa sem depender apenas de CLI\n- selecao de conta no runtime com regras de precedencia\n- migracao compativel com auth legado\n- testes unitarios e de integracao de fluxo principal\n- documentacao de uso e troubleshooting\n\n### Fora de escopo (fase inicial)\n\n- sincronizacao cloud de multiplas contas\n- politicas de equipe via servidor remoto (pode vir na fase 2)\n\n## 4. Requisitos Funcionais\n\n1. Cadastrar varias contas para o mesmo provider.\n2. Definir conta default por provider.\n3. Trocar conta default sem novo login.\n4. Selecionar conta por projeto/sessao via config/env.\n5. Listar contas e visualizar qual esta ativa.\n6. Remover conta especifica sem apagar as demais.\n7. Remover provider inteiro quando desejado.\n8. Ler auth legado sem erro.\n9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider.\n\n## 5. Requisitos Nao Funcionais\n\n1. Backward compatibility total.\n2. Armazenamento local com permissao `0600`, igual ao comportamento atual.\n3. Sem uso de `any`.\n4. Sem degradar tempo de inicializacao de forma perceptivel.\n5. Cobertura de testes para paths criticos (legacy + novo).\n6. Build e typecheck devem passar ao final da implementacao.\n\n## 6. Arquitetura Proposta\n\n### 6.1 Modelo de dados (auth.json v2)\n\nFormato proposto:\n\n```json\n{\n \"openai\": {\n \"default\": \"work\",\n \"accounts\": {\n \"work\": {\n \"type\": \"oauth\",\n \"access\": \"...\",\n \"refresh\": \"...\",\n \"expires\": 1769999999,\n \"accountId\": \"org_work\"\n },\n \"personal\": {\n \"type\": \"api\",\n \"key\": \"sk-...\"\n }\n }\n },\n \"anthropic\": {\n \"default\": \"default\",\n \"accounts\": {\n \"default\": {\n \"type\": \"api\",\n \"key\": \"...\"\n }\n }\n }\n}\n```\n\n### 6.2 Compatibilidade com legado\n\nFormato legado atual:\n\n```json\n{\n \"openai\": { \"type\": \"api\", \"key\": \"...\" }\n}\n```\n\nRegra:\n\n- leitura: se valor de provider estiver no formato legado, tratar em memoria como:\n - `default = \"default\"`\n - `accounts.default = `\n- escrita: qualquer mutacao salva no formato v2\n\n### 6.3 Schema em codigo\n\nArquivo principal: `packages/opencode/src/auth/index.ts`\n\nAdicionar:\n\n- `Auth.Accounts` (objeto com `default` e `accounts`)\n- `Auth.Storage` (record provider -> Accounts)\n- parser que aceite `Info | Accounts` no input e normalize para `Accounts`\n\n## 7. API Interna de Auth\n\nEvolucao da namespace `Auth`:\n\n1. `all(): Promise>`\n2. `list(providerID?: string): Promise | Record>>`\n3. `get(providerID: string, account?: string): Promise`\n4. `set(providerID: string, info: Info, account?: string): Promise`\n5. `use(providerID: string, account: string): Promise`\n6. `remove(providerID: string, account?: string): Promise`\n7. `default(providerID: string): Promise`\n\nRegras:\n\n- `account` default para `\"default\"` no `set` quando provider nao existir\n- `set` em conta nova nao deve alterar default automaticamente se ja existir default\n- `remove(provider, account)`:\n - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica)\n - se remover ultima conta, remover provider inteiro\n\n## 8. Design CLI\n\nArquivo: `packages/opencode/src/cli/cmd/auth.ts`\n\n### 8.1 `opencode auth login`\n\nFluxo:\n\n1. selecionar provider (igual hoje)\n2. executar fluxo de auth (oauth/api/plugin)\n3. perguntar alias da conta:\n - default sugestao: `default`\n - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$`\n4. opcional: \"usar como conta padrao agora?\" (sim/nao)\n\n### 8.2 `opencode auth list`\n\nExibir por provider:\n\n- provider name\n- tipo de cada conta (`api`, `oauth`, `wellknown`)\n- marcador da conta ativa (`*`)\n- metadados relevantes quando existirem (`accountId`)\n\nExemplo:\n\n```txt\nOpenAI\n * work oauth accountId=org_work\n personal api\n```\n\n### 8.3 `opencode auth use`\n\nNovo comando:\n\n```bash\nopencode auth use\nopencode auth use openai personal\n```\n\nCom argumento opcional para modo nao interativo.\nSem argumentos, abre prompt provider -> conta.\n\n### 8.4 `opencode auth logout`\n\nAjustar para permitir:\n\n- remover conta especifica\n- remover provider inteiro (acao explicita)\n\nFluxo recomendado:\n\n1. selecionar provider\n2. selecionar conta ou \"all accounts\"\n3. confirmar quando for \"all accounts\"\n\n### 8.5 Interface obrigatoria para troca de conta (TUI/app)\n\nObjetivo: permitir troca de conta ativa sem sair da interface principal.\n\nRequisitos minimos:\n\n1. Expor acao no TUI (slash command ou dialog de provider) para \"Switch account\".\n2. Listar contas por provider com marcador da conta ativa.\n3. Permitir trocar conta com confirmacao visual imediata.\n4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI.\n5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo).\n\n## 9. Resolucao de Conta no Runtime\n\nArquivos alvo:\n\n- `packages/opencode/src/provider/auth.ts`\n- `packages/opencode/src/config/config.ts`\n- pontos de chamada que leem credencial para provider\n\n### 9.1 Precedencia de resolucao\n\n1. override explicito da chamada (quando existir)\n2. config de projeto (`opencode.json`) para conta do provider\n3. env var dedicada (opcional na fase 1, recomendado fase 1.5)\n4. conta default do provider\n\n### 9.2 Chave de configuracao sugerida\n\nNo `opencode.json`:\n\n```json\n{\n \"auth\": {\n \"account\": {\n \"openai\": \"work\",\n \"anthropic\": \"personal\"\n }\n }\n}\n```\n\nSe essa chave for adicionada na fase 1, deve entrar no schema de config com docs.\nSe ficar para fase 2, manter apenas default global via `auth use`.\n\n## 10. OAuth e Plugins\n\nNos fluxos OAuth/plugin:\n\n- continuar salvando `accountId` quando o provider retornar esse campo\n- associar resultado ao alias escolhido no CLI\n- manter comportamento atual para providers que retornam `provider` custom no callback\n\n## 11. Migracao\n\n### 11.1 Estrategia\n\n- migracao lazy na leitura\n- persistencia no novo formato na primeira escrita\n- sem comando manual obrigatorio\n\n### 11.2 Integridade\n\n- parse robusto por item\n- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira\n- escrita atomica via `Bun.write`\n\n### 11.3 Rollback\n\nNao ha rollback automatico de arquivo.\nPara seguranca operacional, documentar backup manual:\n\n```bash\ncp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak\n```\n\n## 12. Plano de Testes\n\n### 12.1 Unitarios (Auth core)\n\nArquivo sugerido: `packages/opencode/test/auth/auth.test.ts`\n\nCenarios:\n\n1. ler formato legado e normalizar corretamente\n2. `set` cria provider e conta default\n3. `set` adiciona segunda conta sem trocar default\n4. `use` troca default para conta existente\n5. `remove` conta nao default preserva default\n6. `remove` conta default promove outra\n7. `remove` ultima conta apaga provider\n8. parser ignora entradas invalidas\n\n### 12.2 CLI integration\n\nArquivos em `packages/opencode/test/...`\n\n1. `auth list` mostra marcador de default\n2. `auth use` altera conta ativa\n3. `auth logout` remove somente conta escolhida\n\n### 12.3 Runtime integration\n\n1. provider resolve conta por default\n2. provider resolve conta por override de config\n3. fallback legado continua funcional\n\n## 13. Riscos e Mitigacoes\n\n1. Risco: quebrar leitura de auth legado.\n Mitigacao: parser dual + testes de fixture legado.\n\n2. Risco: confusao de UX com muitas opcoes.\n Mitigacao: defaults fortes e fluxo interativo curto.\n\n3. Risco: conflito de nomes de conta.\n Mitigacao: validacao e confirmacao de overwrite.\n\n4. Risco: providers com oauth diferente por tenant.\n Mitigacao: preservar `accountId` e expor no `auth list`.\n\n## 14. Fases de Entrega\n\n### Fase 1 (core)\n\n- schema/auth storage v2\n- compat legado\n- API interna (`get/set/use/remove/list`)\n- testes unitarios de auth\n\n### Fase 2 (CLI)\n\n- `auth login` com alias\n- `auth list` com contas\n- `auth use`\n- `auth logout` granular\n- interface no TUI/app para troca de conta ativa\n- testes de CLI\n\n### Fase 3 (runtime/config)\n\n- resolucao por conta ativa\n- override por config/env (se aprovado no escopo)\n- testes de integracao de provider\n\n### Fase 4 (docs e hardening)\n\n- docs de comandos novos\n- troubleshooting multi-auth\n- validacao final com cenarios work/personal\n\n## 15. Definicao de Pronto (DoD)\n\n1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente.\n2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar.\n3. Sessao usa a conta esperada conforme regra de precedencia.\n4. Usuarios legados nao precisam fazer nenhuma acao manual.\n5. Suite de testes adicionada e passando nos modulos afetados.\n6. Docs publicadas com exemplos reais.\n7. Existe interface no TUI/app para trocar conta ativa por provider.\n8. `typecheck` e `build` passam sem erros nos pacotes afetados.\n\n## 16. Checklist de Implementacao\n\n1. Atualizar `Auth` schema e normalizacao legado.\n2. Implementar API interna multi-account.\n3. Atualizar fluxos de `auth login/list/logout`.\n4. Adicionar `auth use`.\n5. Integrar resolucao de conta no runtime.\n6. Adicionar/atualizar testes.\n7. Atualizar docs PT-BR + EN.\n8. Validar manualmente com duas contas no mesmo provider.\n9. Implementar interface no TUI/app para troca de conta.\n10. Garantir `build` e `typecheck` verdes antes de merge.\n\n## 17. Exemplos de Uso Final\n\n```bash\n# login da conta corporativa\nopencode auth login\n# alias: work\n\n# login da conta pessoal\nopencode auth login\n# alias: personal\n\n# ver estado\nopencode auth list\n\n# trocar conta ativa\nopencode auth use openai personal\n\n# remover so a conta pessoal\nopencode auth logout\n```\n\n## 18. Open Questions\n\n1. Queremos incluir override por config ja na primeira entrega?\n2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal?\n3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`?\n", - "startedAt": "2026-02-13T00:01:23.702Z", + "startedAt": "2026-02-13T00:01:53.900Z", "model": "minimax-coding-plan/MiniMax-M2.5", "agent": "opencode" } \ No newline at end of file From 03e04073eecd6ee2fd11e69fdc20d7f87b875575 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 21:03:23 -0300 Subject: [PATCH 07/12] Ralph iteration 1: work in progress --- .ralph/ralph-history.json | 8 ++++---- .ralph/ralph-loop.state.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json index 4c40196ce4d..bd181ff6274 100644 --- a/.ralph/ralph-history.json +++ b/.ralph/ralph-history.json @@ -2,9 +2,9 @@ "iterations": [ { "iteration": 1, - "startedAt": "2026-02-13T00:01:58.908Z", - "endedAt": "2026-02-13T00:02:05.853Z", - "durationMs": 1943, + "startedAt": "2026-02-13T00:03:17.192Z", + "endedAt": "2026-02-13T00:03:23.725Z", + "durationMs": 1542, "toolsUsed": {}, "filesModified": [ "packages/console/app/public/email" @@ -17,7 +17,7 @@ ] } ], - "totalDurationMs": 1943, + "totalDurationMs": 1542, "struggleIndicators": { "repeatedErrors": { "ProviderModelNotFoundError: ProviderModelNotFoundError": 1, diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json index c6471063118..5ec757c0a19 100644 --- a/.ralph/ralph-loop.state.json +++ b/.ralph/ralph-loop.state.json @@ -7,7 +7,7 @@ "tasksMode": false, "taskPromise": "READY_FOR_NEXT_TASK", "prompt": "\n# Plano Completo: Multi-Auth para OpenCode\n\n## 1. Objetivo\n\nPermitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com:\n\n- troca rapida de conta ativa\n- selecao explicita por projeto/sessao quando necessario\n- compatibilidade total com formato legado de credenciais\n- zero regressao para usuarios com uma unica conta\n\n## 2. Problema Atual\n\nHoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider.\nIsso impede cenarios comuns:\n\n- separar conta pessoal e conta corporativa no mesmo provider\n- alternar billing sem relogar\n- padronizar por projeto (repo A usa `work`, repo B usa `personal`)\n\n## 3. Escopo\n\n### Em escopo\n\n- novo modelo de dados para auth com contas nomeadas\n- comandos CLI para gerenciar contas\n- interface no TUI/app para trocar conta ativa sem depender apenas de CLI\n- selecao de conta no runtime com regras de precedencia\n- migracao compativel com auth legado\n- testes unitarios e de integracao de fluxo principal\n- documentacao de uso e troubleshooting\n\n### Fora de escopo (fase inicial)\n\n- sincronizacao cloud de multiplas contas\n- politicas de equipe via servidor remoto (pode vir na fase 2)\n\n## 4. Requisitos Funcionais\n\n1. Cadastrar varias contas para o mesmo provider.\n2. Definir conta default por provider.\n3. Trocar conta default sem novo login.\n4. Selecionar conta por projeto/sessao via config/env.\n5. Listar contas e visualizar qual esta ativa.\n6. Remover conta especifica sem apagar as demais.\n7. Remover provider inteiro quando desejado.\n8. Ler auth legado sem erro.\n9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider.\n\n## 5. Requisitos Nao Funcionais\n\n1. Backward compatibility total.\n2. Armazenamento local com permissao `0600`, igual ao comportamento atual.\n3. Sem uso de `any`.\n4. Sem degradar tempo de inicializacao de forma perceptivel.\n5. Cobertura de testes para paths criticos (legacy + novo).\n6. Build e typecheck devem passar ao final da implementacao.\n\n## 6. Arquitetura Proposta\n\n### 6.1 Modelo de dados (auth.json v2)\n\nFormato proposto:\n\n```json\n{\n \"openai\": {\n \"default\": \"work\",\n \"accounts\": {\n \"work\": {\n \"type\": \"oauth\",\n \"access\": \"...\",\n \"refresh\": \"...\",\n \"expires\": 1769999999,\n \"accountId\": \"org_work\"\n },\n \"personal\": {\n \"type\": \"api\",\n \"key\": \"sk-...\"\n }\n }\n },\n \"anthropic\": {\n \"default\": \"default\",\n \"accounts\": {\n \"default\": {\n \"type\": \"api\",\n \"key\": \"...\"\n }\n }\n }\n}\n```\n\n### 6.2 Compatibilidade com legado\n\nFormato legado atual:\n\n```json\n{\n \"openai\": { \"type\": \"api\", \"key\": \"...\" }\n}\n```\n\nRegra:\n\n- leitura: se valor de provider estiver no formato legado, tratar em memoria como:\n - `default = \"default\"`\n - `accounts.default = `\n- escrita: qualquer mutacao salva no formato v2\n\n### 6.3 Schema em codigo\n\nArquivo principal: `packages/opencode/src/auth/index.ts`\n\nAdicionar:\n\n- `Auth.Accounts` (objeto com `default` e `accounts`)\n- `Auth.Storage` (record provider -> Accounts)\n- parser que aceite `Info | Accounts` no input e normalize para `Accounts`\n\n## 7. API Interna de Auth\n\nEvolucao da namespace `Auth`:\n\n1. `all(): Promise>`\n2. `list(providerID?: string): Promise | Record>>`\n3. `get(providerID: string, account?: string): Promise`\n4. `set(providerID: string, info: Info, account?: string): Promise`\n5. `use(providerID: string, account: string): Promise`\n6. `remove(providerID: string, account?: string): Promise`\n7. `default(providerID: string): Promise`\n\nRegras:\n\n- `account` default para `\"default\"` no `set` quando provider nao existir\n- `set` em conta nova nao deve alterar default automaticamente se ja existir default\n- `remove(provider, account)`:\n - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica)\n - se remover ultima conta, remover provider inteiro\n\n## 8. Design CLI\n\nArquivo: `packages/opencode/src/cli/cmd/auth.ts`\n\n### 8.1 `opencode auth login`\n\nFluxo:\n\n1. selecionar provider (igual hoje)\n2. executar fluxo de auth (oauth/api/plugin)\n3. perguntar alias da conta:\n - default sugestao: `default`\n - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$`\n4. opcional: \"usar como conta padrao agora?\" (sim/nao)\n\n### 8.2 `opencode auth list`\n\nExibir por provider:\n\n- provider name\n- tipo de cada conta (`api`, `oauth`, `wellknown`)\n- marcador da conta ativa (`*`)\n- metadados relevantes quando existirem (`accountId`)\n\nExemplo:\n\n```txt\nOpenAI\n * work oauth accountId=org_work\n personal api\n```\n\n### 8.3 `opencode auth use`\n\nNovo comando:\n\n```bash\nopencode auth use\nopencode auth use openai personal\n```\n\nCom argumento opcional para modo nao interativo.\nSem argumentos, abre prompt provider -> conta.\n\n### 8.4 `opencode auth logout`\n\nAjustar para permitir:\n\n- remover conta especifica\n- remover provider inteiro (acao explicita)\n\nFluxo recomendado:\n\n1. selecionar provider\n2. selecionar conta ou \"all accounts\"\n3. confirmar quando for \"all accounts\"\n\n### 8.5 Interface obrigatoria para troca de conta (TUI/app)\n\nObjetivo: permitir troca de conta ativa sem sair da interface principal.\n\nRequisitos minimos:\n\n1. Expor acao no TUI (slash command ou dialog de provider) para \"Switch account\".\n2. Listar contas por provider com marcador da conta ativa.\n3. Permitir trocar conta com confirmacao visual imediata.\n4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI.\n5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo).\n\n## 9. Resolucao de Conta no Runtime\n\nArquivos alvo:\n\n- `packages/opencode/src/provider/auth.ts`\n- `packages/opencode/src/config/config.ts`\n- pontos de chamada que leem credencial para provider\n\n### 9.1 Precedencia de resolucao\n\n1. override explicito da chamada (quando existir)\n2. config de projeto (`opencode.json`) para conta do provider\n3. env var dedicada (opcional na fase 1, recomendado fase 1.5)\n4. conta default do provider\n\n### 9.2 Chave de configuracao sugerida\n\nNo `opencode.json`:\n\n```json\n{\n \"auth\": {\n \"account\": {\n \"openai\": \"work\",\n \"anthropic\": \"personal\"\n }\n }\n}\n```\n\nSe essa chave for adicionada na fase 1, deve entrar no schema de config com docs.\nSe ficar para fase 2, manter apenas default global via `auth use`.\n\n## 10. OAuth e Plugins\n\nNos fluxos OAuth/plugin:\n\n- continuar salvando `accountId` quando o provider retornar esse campo\n- associar resultado ao alias escolhido no CLI\n- manter comportamento atual para providers que retornam `provider` custom no callback\n\n## 11. Migracao\n\n### 11.1 Estrategia\n\n- migracao lazy na leitura\n- persistencia no novo formato na primeira escrita\n- sem comando manual obrigatorio\n\n### 11.2 Integridade\n\n- parse robusto por item\n- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira\n- escrita atomica via `Bun.write`\n\n### 11.3 Rollback\n\nNao ha rollback automatico de arquivo.\nPara seguranca operacional, documentar backup manual:\n\n```bash\ncp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak\n```\n\n## 12. Plano de Testes\n\n### 12.1 Unitarios (Auth core)\n\nArquivo sugerido: `packages/opencode/test/auth/auth.test.ts`\n\nCenarios:\n\n1. ler formato legado e normalizar corretamente\n2. `set` cria provider e conta default\n3. `set` adiciona segunda conta sem trocar default\n4. `use` troca default para conta existente\n5. `remove` conta nao default preserva default\n6. `remove` conta default promove outra\n7. `remove` ultima conta apaga provider\n8. parser ignora entradas invalidas\n\n### 12.2 CLI integration\n\nArquivos em `packages/opencode/test/...`\n\n1. `auth list` mostra marcador de default\n2. `auth use` altera conta ativa\n3. `auth logout` remove somente conta escolhida\n\n### 12.3 Runtime integration\n\n1. provider resolve conta por default\n2. provider resolve conta por override de config\n3. fallback legado continua funcional\n\n## 13. Riscos e Mitigacoes\n\n1. Risco: quebrar leitura de auth legado.\n Mitigacao: parser dual + testes de fixture legado.\n\n2. Risco: confusao de UX com muitas opcoes.\n Mitigacao: defaults fortes e fluxo interativo curto.\n\n3. Risco: conflito de nomes de conta.\n Mitigacao: validacao e confirmacao de overwrite.\n\n4. Risco: providers com oauth diferente por tenant.\n Mitigacao: preservar `accountId` e expor no `auth list`.\n\n## 14. Fases de Entrega\n\n### Fase 1 (core)\n\n- schema/auth storage v2\n- compat legado\n- API interna (`get/set/use/remove/list`)\n- testes unitarios de auth\n\n### Fase 2 (CLI)\n\n- `auth login` com alias\n- `auth list` com contas\n- `auth use`\n- `auth logout` granular\n- interface no TUI/app para troca de conta ativa\n- testes de CLI\n\n### Fase 3 (runtime/config)\n\n- resolucao por conta ativa\n- override por config/env (se aprovado no escopo)\n- testes de integracao de provider\n\n### Fase 4 (docs e hardening)\n\n- docs de comandos novos\n- troubleshooting multi-auth\n- validacao final com cenarios work/personal\n\n## 15. Definicao de Pronto (DoD)\n\n1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente.\n2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar.\n3. Sessao usa a conta esperada conforme regra de precedencia.\n4. Usuarios legados nao precisam fazer nenhuma acao manual.\n5. Suite de testes adicionada e passando nos modulos afetados.\n6. Docs publicadas com exemplos reais.\n7. Existe interface no TUI/app para trocar conta ativa por provider.\n8. `typecheck` e `build` passam sem erros nos pacotes afetados.\n\n## 16. Checklist de Implementacao\n\n1. Atualizar `Auth` schema e normalizacao legado.\n2. Implementar API interna multi-account.\n3. Atualizar fluxos de `auth login/list/logout`.\n4. Adicionar `auth use`.\n5. Integrar resolucao de conta no runtime.\n6. Adicionar/atualizar testes.\n7. Atualizar docs PT-BR + EN.\n8. Validar manualmente com duas contas no mesmo provider.\n9. Implementar interface no TUI/app para troca de conta.\n10. Garantir `build` e `typecheck` verdes antes de merge.\n\n## 17. Exemplos de Uso Final\n\n```bash\n# login da conta corporativa\nopencode auth login\n# alias: work\n\n# login da conta pessoal\nopencode auth login\n# alias: personal\n\n# ver estado\nopencode auth list\n\n# trocar conta ativa\nopencode auth use openai personal\n\n# remover so a conta pessoal\nopencode auth logout\n```\n\n## 18. Open Questions\n\n1. Queremos incluir override por config ja na primeira entrega?\n2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal?\n3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`?\n", - "startedAt": "2026-02-13T00:01:53.900Z", + "startedAt": "2026-02-13T00:03:12.191Z", "model": "minimax-coding-plan/MiniMax-M2.5", "agent": "opencode" } \ No newline at end of file From a16779d306b0b8ffd56e5cdd456a6ec9bad5ee3a Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 21:07:30 -0300 Subject: [PATCH 08/12] Ralph iteration 1: work in progress --- .ralph/ralph-history.json | 14 ++++++-------- .ralph/ralph-loop.state.json | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json index bd181ff6274..3bfc002cd6a 100644 --- a/.ralph/ralph-history.json +++ b/.ralph/ralph-history.json @@ -2,9 +2,9 @@ "iterations": [ { "iteration": 1, - "startedAt": "2026-02-13T00:03:17.192Z", - "endedAt": "2026-02-13T00:03:23.725Z", - "durationMs": 1542, + "startedAt": "2026-02-13T00:07:21.551Z", + "endedAt": "2026-02-13T00:07:30.954Z", + "durationMs": 4367, "toolsUsed": {}, "filesModified": [ "packages/console/app/public/email" @@ -12,16 +12,14 @@ "exitCode": 0, "completionDetected": false, "errors": [ - "ProviderModelNotFoundError: ProviderModelNotFoundError", - "\u001b[91m\u001b[1mError: \u001b[0mModel not found: minimax-coding-plan/MiniMax-M2.5." + "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable." ] } ], - "totalDurationMs": 1542, + "totalDurationMs": 4367, "struggleIndicators": { "repeatedErrors": { - "ProviderModelNotFoundError: ProviderModelNotFoundError": 1, - "\u001b[91m\u001b[1mError: \u001b[0mModel not found: minimax-coding-plan/MiniMax-M2.5.": 1 + "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHRO": 1 }, "noProgressIterations": 0, "shortIterations": 1 diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json index 5ec757c0a19..9b4c5ceff7d 100644 --- a/.ralph/ralph-loop.state.json +++ b/.ralph/ralph-loop.state.json @@ -7,7 +7,7 @@ "tasksMode": false, "taskPromise": "READY_FOR_NEXT_TASK", "prompt": "\n# Plano Completo: Multi-Auth para OpenCode\n\n## 1. Objetivo\n\nPermitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com:\n\n- troca rapida de conta ativa\n- selecao explicita por projeto/sessao quando necessario\n- compatibilidade total com formato legado de credenciais\n- zero regressao para usuarios com uma unica conta\n\n## 2. Problema Atual\n\nHoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider.\nIsso impede cenarios comuns:\n\n- separar conta pessoal e conta corporativa no mesmo provider\n- alternar billing sem relogar\n- padronizar por projeto (repo A usa `work`, repo B usa `personal`)\n\n## 3. Escopo\n\n### Em escopo\n\n- novo modelo de dados para auth com contas nomeadas\n- comandos CLI para gerenciar contas\n- interface no TUI/app para trocar conta ativa sem depender apenas de CLI\n- selecao de conta no runtime com regras de precedencia\n- migracao compativel com auth legado\n- testes unitarios e de integracao de fluxo principal\n- documentacao de uso e troubleshooting\n\n### Fora de escopo (fase inicial)\n\n- sincronizacao cloud de multiplas contas\n- politicas de equipe via servidor remoto (pode vir na fase 2)\n\n## 4. Requisitos Funcionais\n\n1. Cadastrar varias contas para o mesmo provider.\n2. Definir conta default por provider.\n3. Trocar conta default sem novo login.\n4. Selecionar conta por projeto/sessao via config/env.\n5. Listar contas e visualizar qual esta ativa.\n6. Remover conta especifica sem apagar as demais.\n7. Remover provider inteiro quando desejado.\n8. Ler auth legado sem erro.\n9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider.\n\n## 5. Requisitos Nao Funcionais\n\n1. Backward compatibility total.\n2. Armazenamento local com permissao `0600`, igual ao comportamento atual.\n3. Sem uso de `any`.\n4. Sem degradar tempo de inicializacao de forma perceptivel.\n5. Cobertura de testes para paths criticos (legacy + novo).\n6. Build e typecheck devem passar ao final da implementacao.\n\n## 6. Arquitetura Proposta\n\n### 6.1 Modelo de dados (auth.json v2)\n\nFormato proposto:\n\n```json\n{\n \"openai\": {\n \"default\": \"work\",\n \"accounts\": {\n \"work\": {\n \"type\": \"oauth\",\n \"access\": \"...\",\n \"refresh\": \"...\",\n \"expires\": 1769999999,\n \"accountId\": \"org_work\"\n },\n \"personal\": {\n \"type\": \"api\",\n \"key\": \"sk-...\"\n }\n }\n },\n \"anthropic\": {\n \"default\": \"default\",\n \"accounts\": {\n \"default\": {\n \"type\": \"api\",\n \"key\": \"...\"\n }\n }\n }\n}\n```\n\n### 6.2 Compatibilidade com legado\n\nFormato legado atual:\n\n```json\n{\n \"openai\": { \"type\": \"api\", \"key\": \"...\" }\n}\n```\n\nRegra:\n\n- leitura: se valor de provider estiver no formato legado, tratar em memoria como:\n - `default = \"default\"`\n - `accounts.default = `\n- escrita: qualquer mutacao salva no formato v2\n\n### 6.3 Schema em codigo\n\nArquivo principal: `packages/opencode/src/auth/index.ts`\n\nAdicionar:\n\n- `Auth.Accounts` (objeto com `default` e `accounts`)\n- `Auth.Storage` (record provider -> Accounts)\n- parser que aceite `Info | Accounts` no input e normalize para `Accounts`\n\n## 7. API Interna de Auth\n\nEvolucao da namespace `Auth`:\n\n1. `all(): Promise>`\n2. `list(providerID?: string): Promise | Record>>`\n3. `get(providerID: string, account?: string): Promise`\n4. `set(providerID: string, info: Info, account?: string): Promise`\n5. `use(providerID: string, account: string): Promise`\n6. `remove(providerID: string, account?: string): Promise`\n7. `default(providerID: string): Promise`\n\nRegras:\n\n- `account` default para `\"default\"` no `set` quando provider nao existir\n- `set` em conta nova nao deve alterar default automaticamente se ja existir default\n- `remove(provider, account)`:\n - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica)\n - se remover ultima conta, remover provider inteiro\n\n## 8. Design CLI\n\nArquivo: `packages/opencode/src/cli/cmd/auth.ts`\n\n### 8.1 `opencode auth login`\n\nFluxo:\n\n1. selecionar provider (igual hoje)\n2. executar fluxo de auth (oauth/api/plugin)\n3. perguntar alias da conta:\n - default sugestao: `default`\n - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$`\n4. opcional: \"usar como conta padrao agora?\" (sim/nao)\n\n### 8.2 `opencode auth list`\n\nExibir por provider:\n\n- provider name\n- tipo de cada conta (`api`, `oauth`, `wellknown`)\n- marcador da conta ativa (`*`)\n- metadados relevantes quando existirem (`accountId`)\n\nExemplo:\n\n```txt\nOpenAI\n * work oauth accountId=org_work\n personal api\n```\n\n### 8.3 `opencode auth use`\n\nNovo comando:\n\n```bash\nopencode auth use\nopencode auth use openai personal\n```\n\nCom argumento opcional para modo nao interativo.\nSem argumentos, abre prompt provider -> conta.\n\n### 8.4 `opencode auth logout`\n\nAjustar para permitir:\n\n- remover conta especifica\n- remover provider inteiro (acao explicita)\n\nFluxo recomendado:\n\n1. selecionar provider\n2. selecionar conta ou \"all accounts\"\n3. confirmar quando for \"all accounts\"\n\n### 8.5 Interface obrigatoria para troca de conta (TUI/app)\n\nObjetivo: permitir troca de conta ativa sem sair da interface principal.\n\nRequisitos minimos:\n\n1. Expor acao no TUI (slash command ou dialog de provider) para \"Switch account\".\n2. Listar contas por provider com marcador da conta ativa.\n3. Permitir trocar conta com confirmacao visual imediata.\n4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI.\n5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo).\n\n## 9. Resolucao de Conta no Runtime\n\nArquivos alvo:\n\n- `packages/opencode/src/provider/auth.ts`\n- `packages/opencode/src/config/config.ts`\n- pontos de chamada que leem credencial para provider\n\n### 9.1 Precedencia de resolucao\n\n1. override explicito da chamada (quando existir)\n2. config de projeto (`opencode.json`) para conta do provider\n3. env var dedicada (opcional na fase 1, recomendado fase 1.5)\n4. conta default do provider\n\n### 9.2 Chave de configuracao sugerida\n\nNo `opencode.json`:\n\n```json\n{\n \"auth\": {\n \"account\": {\n \"openai\": \"work\",\n \"anthropic\": \"personal\"\n }\n }\n}\n```\n\nSe essa chave for adicionada na fase 1, deve entrar no schema de config com docs.\nSe ficar para fase 2, manter apenas default global via `auth use`.\n\n## 10. OAuth e Plugins\n\nNos fluxos OAuth/plugin:\n\n- continuar salvando `accountId` quando o provider retornar esse campo\n- associar resultado ao alias escolhido no CLI\n- manter comportamento atual para providers que retornam `provider` custom no callback\n\n## 11. Migracao\n\n### 11.1 Estrategia\n\n- migracao lazy na leitura\n- persistencia no novo formato na primeira escrita\n- sem comando manual obrigatorio\n\n### 11.2 Integridade\n\n- parse robusto por item\n- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira\n- escrita atomica via `Bun.write`\n\n### 11.3 Rollback\n\nNao ha rollback automatico de arquivo.\nPara seguranca operacional, documentar backup manual:\n\n```bash\ncp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak\n```\n\n## 12. Plano de Testes\n\n### 12.1 Unitarios (Auth core)\n\nArquivo sugerido: `packages/opencode/test/auth/auth.test.ts`\n\nCenarios:\n\n1. ler formato legado e normalizar corretamente\n2. `set` cria provider e conta default\n3. `set` adiciona segunda conta sem trocar default\n4. `use` troca default para conta existente\n5. `remove` conta nao default preserva default\n6. `remove` conta default promove outra\n7. `remove` ultima conta apaga provider\n8. parser ignora entradas invalidas\n\n### 12.2 CLI integration\n\nArquivos em `packages/opencode/test/...`\n\n1. `auth list` mostra marcador de default\n2. `auth use` altera conta ativa\n3. `auth logout` remove somente conta escolhida\n\n### 12.3 Runtime integration\n\n1. provider resolve conta por default\n2. provider resolve conta por override de config\n3. fallback legado continua funcional\n\n## 13. Riscos e Mitigacoes\n\n1. Risco: quebrar leitura de auth legado.\n Mitigacao: parser dual + testes de fixture legado.\n\n2. Risco: confusao de UX com muitas opcoes.\n Mitigacao: defaults fortes e fluxo interativo curto.\n\n3. Risco: conflito de nomes de conta.\n Mitigacao: validacao e confirmacao de overwrite.\n\n4. Risco: providers com oauth diferente por tenant.\n Mitigacao: preservar `accountId` e expor no `auth list`.\n\n## 14. Fases de Entrega\n\n### Fase 1 (core)\n\n- schema/auth storage v2\n- compat legado\n- API interna (`get/set/use/remove/list`)\n- testes unitarios de auth\n\n### Fase 2 (CLI)\n\n- `auth login` com alias\n- `auth list` com contas\n- `auth use`\n- `auth logout` granular\n- interface no TUI/app para troca de conta ativa\n- testes de CLI\n\n### Fase 3 (runtime/config)\n\n- resolucao por conta ativa\n- override por config/env (se aprovado no escopo)\n- testes de integracao de provider\n\n### Fase 4 (docs e hardening)\n\n- docs de comandos novos\n- troubleshooting multi-auth\n- validacao final com cenarios work/personal\n\n## 15. Definicao de Pronto (DoD)\n\n1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente.\n2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar.\n3. Sessao usa a conta esperada conforme regra de precedencia.\n4. Usuarios legados nao precisam fazer nenhuma acao manual.\n5. Suite de testes adicionada e passando nos modulos afetados.\n6. Docs publicadas com exemplos reais.\n7. Existe interface no TUI/app para trocar conta ativa por provider.\n8. `typecheck` e `build` passam sem erros nos pacotes afetados.\n\n## 16. Checklist de Implementacao\n\n1. Atualizar `Auth` schema e normalizacao legado.\n2. Implementar API interna multi-account.\n3. Atualizar fluxos de `auth login/list/logout`.\n4. Adicionar `auth use`.\n5. Integrar resolucao de conta no runtime.\n6. Adicionar/atualizar testes.\n7. Atualizar docs PT-BR + EN.\n8. Validar manualmente com duas contas no mesmo provider.\n9. Implementar interface no TUI/app para troca de conta.\n10. Garantir `build` e `typecheck` verdes antes de merge.\n\n## 17. Exemplos de Uso Final\n\n```bash\n# login da conta corporativa\nopencode auth login\n# alias: work\n\n# login da conta pessoal\nopencode auth login\n# alias: personal\n\n# ver estado\nopencode auth list\n\n# trocar conta ativa\nopencode auth use openai personal\n\n# remover so a conta pessoal\nopencode auth logout\n```\n\n## 18. Open Questions\n\n1. Queremos incluir override por config ja na primeira entrega?\n2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal?\n3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`?\n", - "startedAt": "2026-02-13T00:03:12.191Z", + "startedAt": "2026-02-13T00:07:16.540Z", "model": "minimax-coding-plan/MiniMax-M2.5", "agent": "opencode" } \ No newline at end of file From 99c56bd8ad022280bdf4751474c7d01364cf0024 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 21:07:46 -0300 Subject: [PATCH 09/12] Ralph iteration 2: work in progress --- .ralph/ralph-history.json | 21 ++++++++++++++++++--- .ralph/ralph-loop.state.json | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json index 3bfc002cd6a..80d8f00d9b1 100644 --- a/.ralph/ralph-history.json +++ b/.ralph/ralph-history.json @@ -14,14 +14,29 @@ "errors": [ "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable." ] + }, + { + "iteration": 2, + "startedAt": "2026-02-13T00:07:37.067Z", + "endedAt": "2026-02-13T00:07:46.074Z", + "durationMs": 4003, + "toolsUsed": {}, + "filesModified": [ + "packages/console/app/public/email" + ], + "exitCode": 0, + "completionDetected": false, + "errors": [ + "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable." + ] } ], - "totalDurationMs": 4367, + "totalDurationMs": 8370, "struggleIndicators": { "repeatedErrors": { - "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHRO": 1 + "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHRO": 2 }, "noProgressIterations": 0, - "shortIterations": 1 + "shortIterations": 2 } } \ No newline at end of file diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json index 9b4c5ceff7d..4f65a38a4fe 100644 --- a/.ralph/ralph-loop.state.json +++ b/.ralph/ralph-loop.state.json @@ -1,6 +1,6 @@ { "active": true, - "iteration": 1, + "iteration": 2, "minIterations": 1, "maxIterations": 50, "completionPromise": "COMPLETE", From ffd351c0b6e6fa6e4e3919bf520b577c1a667e85 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 22:13:28 -0300 Subject: [PATCH 10/12] feat: update multi-auth flow and fix typecheck regressions --- .ralph/ralph-history.json | 42 -------- .ralph/ralph-loop.state.json | 13 --- packages/app/src/context/local.tsx | 6 +- .../src/types/tauri-clipboard-manager.d.ts | 11 +++ packages/opencode/src/auth/index.ts | 2 +- packages/opencode/src/cli/cmd/auth.ts | 2 +- .../cli/cmd/tui/component/dialog-provider.tsx | 69 ++++++++++++++ packages/opencode/src/config/config.ts | 9 +- packages/opencode/src/provider/provider.ts | 19 +++- packages/opencode/src/server/server.ts | 94 ++++++++++++++++++ packages/opencode/test/config/config.test.ts | 11 ++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 67 +++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 95 +++++++++++++++++-- packages/ui/src/components/diff-ssr.tsx | 45 ++------- packages/ui/src/components/diff.tsx | 21 +--- packages/ui/src/pierre/index.ts | 2 +- packages/ui/src/pierre/virtualizer.ts | 75 ++------------- packages/ui/src/pierre/worker.ts | 1 - 18 files changed, 379 insertions(+), 205 deletions(-) delete mode 100644 .ralph/ralph-history.json delete mode 100644 .ralph/ralph-loop.state.json create mode 100644 packages/desktop/src/types/tauri-clipboard-manager.d.ts diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json deleted file mode 100644 index 80d8f00d9b1..00000000000 --- a/.ralph/ralph-history.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "iterations": [ - { - "iteration": 1, - "startedAt": "2026-02-13T00:07:21.551Z", - "endedAt": "2026-02-13T00:07:30.954Z", - "durationMs": 4367, - "toolsUsed": {}, - "filesModified": [ - "packages/console/app/public/email" - ], - "exitCode": 0, - "completionDetected": false, - "errors": [ - "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable." - ] - }, - { - "iteration": 2, - "startedAt": "2026-02-13T00:07:37.067Z", - "endedAt": "2026-02-13T00:07:46.074Z", - "durationMs": 4003, - "toolsUsed": {}, - "filesModified": [ - "packages/console/app/public/email" - ], - "exitCode": 0, - "completionDetected": false, - "errors": [ - "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable." - ] - } - ], - "totalDurationMs": 8370, - "struggleIndicators": { - "repeatedErrors": { - "\u001b[91m\u001b[1mError: \u001b[0mAnthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHRO": 2 - }, - "noProgressIterations": 0, - "shortIterations": 2 - } -} \ No newline at end of file diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json deleted file mode 100644 index 4f65a38a4fe..00000000000 --- a/.ralph/ralph-loop.state.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "active": true, - "iteration": 2, - "minIterations": 1, - "maxIterations": 50, - "completionPromise": "COMPLETE", - "tasksMode": false, - "taskPromise": "READY_FOR_NEXT_TASK", - "prompt": "\n# Plano Completo: Multi-Auth para OpenCode\n\n## 1. Objetivo\n\nPermitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com:\n\n- troca rapida de conta ativa\n- selecao explicita por projeto/sessao quando necessario\n- compatibilidade total com formato legado de credenciais\n- zero regressao para usuarios com uma unica conta\n\n## 2. Problema Atual\n\nHoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider.\nIsso impede cenarios comuns:\n\n- separar conta pessoal e conta corporativa no mesmo provider\n- alternar billing sem relogar\n- padronizar por projeto (repo A usa `work`, repo B usa `personal`)\n\n## 3. Escopo\n\n### Em escopo\n\n- novo modelo de dados para auth com contas nomeadas\n- comandos CLI para gerenciar contas\n- interface no TUI/app para trocar conta ativa sem depender apenas de CLI\n- selecao de conta no runtime com regras de precedencia\n- migracao compativel com auth legado\n- testes unitarios e de integracao de fluxo principal\n- documentacao de uso e troubleshooting\n\n### Fora de escopo (fase inicial)\n\n- sincronizacao cloud de multiplas contas\n- politicas de equipe via servidor remoto (pode vir na fase 2)\n\n## 4. Requisitos Funcionais\n\n1. Cadastrar varias contas para o mesmo provider.\n2. Definir conta default por provider.\n3. Trocar conta default sem novo login.\n4. Selecionar conta por projeto/sessao via config/env.\n5. Listar contas e visualizar qual esta ativa.\n6. Remover conta especifica sem apagar as demais.\n7. Remover provider inteiro quando desejado.\n8. Ler auth legado sem erro.\n9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider.\n\n## 5. Requisitos Nao Funcionais\n\n1. Backward compatibility total.\n2. Armazenamento local com permissao `0600`, igual ao comportamento atual.\n3. Sem uso de `any`.\n4. Sem degradar tempo de inicializacao de forma perceptivel.\n5. Cobertura de testes para paths criticos (legacy + novo).\n6. Build e typecheck devem passar ao final da implementacao.\n\n## 6. Arquitetura Proposta\n\n### 6.1 Modelo de dados (auth.json v2)\n\nFormato proposto:\n\n```json\n{\n \"openai\": {\n \"default\": \"work\",\n \"accounts\": {\n \"work\": {\n \"type\": \"oauth\",\n \"access\": \"...\",\n \"refresh\": \"...\",\n \"expires\": 1769999999,\n \"accountId\": \"org_work\"\n },\n \"personal\": {\n \"type\": \"api\",\n \"key\": \"sk-...\"\n }\n }\n },\n \"anthropic\": {\n \"default\": \"default\",\n \"accounts\": {\n \"default\": {\n \"type\": \"api\",\n \"key\": \"...\"\n }\n }\n }\n}\n```\n\n### 6.2 Compatibilidade com legado\n\nFormato legado atual:\n\n```json\n{\n \"openai\": { \"type\": \"api\", \"key\": \"...\" }\n}\n```\n\nRegra:\n\n- leitura: se valor de provider estiver no formato legado, tratar em memoria como:\n - `default = \"default\"`\n - `accounts.default = `\n- escrita: qualquer mutacao salva no formato v2\n\n### 6.3 Schema em codigo\n\nArquivo principal: `packages/opencode/src/auth/index.ts`\n\nAdicionar:\n\n- `Auth.Accounts` (objeto com `default` e `accounts`)\n- `Auth.Storage` (record provider -> Accounts)\n- parser que aceite `Info | Accounts` no input e normalize para `Accounts`\n\n## 7. API Interna de Auth\n\nEvolucao da namespace `Auth`:\n\n1. `all(): Promise>`\n2. `list(providerID?: string): Promise | Record>>`\n3. `get(providerID: string, account?: string): Promise`\n4. `set(providerID: string, info: Info, account?: string): Promise`\n5. `use(providerID: string, account: string): Promise`\n6. `remove(providerID: string, account?: string): Promise`\n7. `default(providerID: string): Promise`\n\nRegras:\n\n- `account` default para `\"default\"` no `set` quando provider nao existir\n- `set` em conta nova nao deve alterar default automaticamente se ja existir default\n- `remove(provider, account)`:\n - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica)\n - se remover ultima conta, remover provider inteiro\n\n## 8. Design CLI\n\nArquivo: `packages/opencode/src/cli/cmd/auth.ts`\n\n### 8.1 `opencode auth login`\n\nFluxo:\n\n1. selecionar provider (igual hoje)\n2. executar fluxo de auth (oauth/api/plugin)\n3. perguntar alias da conta:\n - default sugestao: `default`\n - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$`\n4. opcional: \"usar como conta padrao agora?\" (sim/nao)\n\n### 8.2 `opencode auth list`\n\nExibir por provider:\n\n- provider name\n- tipo de cada conta (`api`, `oauth`, `wellknown`)\n- marcador da conta ativa (`*`)\n- metadados relevantes quando existirem (`accountId`)\n\nExemplo:\n\n```txt\nOpenAI\n * work oauth accountId=org_work\n personal api\n```\n\n### 8.3 `opencode auth use`\n\nNovo comando:\n\n```bash\nopencode auth use\nopencode auth use openai personal\n```\n\nCom argumento opcional para modo nao interativo.\nSem argumentos, abre prompt provider -> conta.\n\n### 8.4 `opencode auth logout`\n\nAjustar para permitir:\n\n- remover conta especifica\n- remover provider inteiro (acao explicita)\n\nFluxo recomendado:\n\n1. selecionar provider\n2. selecionar conta ou \"all accounts\"\n3. confirmar quando for \"all accounts\"\n\n### 8.5 Interface obrigatoria para troca de conta (TUI/app)\n\nObjetivo: permitir troca de conta ativa sem sair da interface principal.\n\nRequisitos minimos:\n\n1. Expor acao no TUI (slash command ou dialog de provider) para \"Switch account\".\n2. Listar contas por provider com marcador da conta ativa.\n3. Permitir trocar conta com confirmacao visual imediata.\n4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI.\n5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo).\n\n## 9. Resolucao de Conta no Runtime\n\nArquivos alvo:\n\n- `packages/opencode/src/provider/auth.ts`\n- `packages/opencode/src/config/config.ts`\n- pontos de chamada que leem credencial para provider\n\n### 9.1 Precedencia de resolucao\n\n1. override explicito da chamada (quando existir)\n2. config de projeto (`opencode.json`) para conta do provider\n3. env var dedicada (opcional na fase 1, recomendado fase 1.5)\n4. conta default do provider\n\n### 9.2 Chave de configuracao sugerida\n\nNo `opencode.json`:\n\n```json\n{\n \"auth\": {\n \"account\": {\n \"openai\": \"work\",\n \"anthropic\": \"personal\"\n }\n }\n}\n```\n\nSe essa chave for adicionada na fase 1, deve entrar no schema de config com docs.\nSe ficar para fase 2, manter apenas default global via `auth use`.\n\n## 10. OAuth e Plugins\n\nNos fluxos OAuth/plugin:\n\n- continuar salvando `accountId` quando o provider retornar esse campo\n- associar resultado ao alias escolhido no CLI\n- manter comportamento atual para providers que retornam `provider` custom no callback\n\n## 11. Migracao\n\n### 11.1 Estrategia\n\n- migracao lazy na leitura\n- persistencia no novo formato na primeira escrita\n- sem comando manual obrigatorio\n\n### 11.2 Integridade\n\n- parse robusto por item\n- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira\n- escrita atomica via `Bun.write`\n\n### 11.3 Rollback\n\nNao ha rollback automatico de arquivo.\nPara seguranca operacional, documentar backup manual:\n\n```bash\ncp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak\n```\n\n## 12. Plano de Testes\n\n### 12.1 Unitarios (Auth core)\n\nArquivo sugerido: `packages/opencode/test/auth/auth.test.ts`\n\nCenarios:\n\n1. ler formato legado e normalizar corretamente\n2. `set` cria provider e conta default\n3. `set` adiciona segunda conta sem trocar default\n4. `use` troca default para conta existente\n5. `remove` conta nao default preserva default\n6. `remove` conta default promove outra\n7. `remove` ultima conta apaga provider\n8. parser ignora entradas invalidas\n\n### 12.2 CLI integration\n\nArquivos em `packages/opencode/test/...`\n\n1. `auth list` mostra marcador de default\n2. `auth use` altera conta ativa\n3. `auth logout` remove somente conta escolhida\n\n### 12.3 Runtime integration\n\n1. provider resolve conta por default\n2. provider resolve conta por override de config\n3. fallback legado continua funcional\n\n## 13. Riscos e Mitigacoes\n\n1. Risco: quebrar leitura de auth legado.\n Mitigacao: parser dual + testes de fixture legado.\n\n2. Risco: confusao de UX com muitas opcoes.\n Mitigacao: defaults fortes e fluxo interativo curto.\n\n3. Risco: conflito de nomes de conta.\n Mitigacao: validacao e confirmacao de overwrite.\n\n4. Risco: providers com oauth diferente por tenant.\n Mitigacao: preservar `accountId` e expor no `auth list`.\n\n## 14. Fases de Entrega\n\n### Fase 1 (core)\n\n- schema/auth storage v2\n- compat legado\n- API interna (`get/set/use/remove/list`)\n- testes unitarios de auth\n\n### Fase 2 (CLI)\n\n- `auth login` com alias\n- `auth list` com contas\n- `auth use`\n- `auth logout` granular\n- interface no TUI/app para troca de conta ativa\n- testes de CLI\n\n### Fase 3 (runtime/config)\n\n- resolucao por conta ativa\n- override por config/env (se aprovado no escopo)\n- testes de integracao de provider\n\n### Fase 4 (docs e hardening)\n\n- docs de comandos novos\n- troubleshooting multi-auth\n- validacao final com cenarios work/personal\n\n## 15. Definicao de Pronto (DoD)\n\n1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente.\n2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar.\n3. Sessao usa a conta esperada conforme regra de precedencia.\n4. Usuarios legados nao precisam fazer nenhuma acao manual.\n5. Suite de testes adicionada e passando nos modulos afetados.\n6. Docs publicadas com exemplos reais.\n7. Existe interface no TUI/app para trocar conta ativa por provider.\n8. `typecheck` e `build` passam sem erros nos pacotes afetados.\n\n## 16. Checklist de Implementacao\n\n1. Atualizar `Auth` schema e normalizacao legado.\n2. Implementar API interna multi-account.\n3. Atualizar fluxos de `auth login/list/logout`.\n4. Adicionar `auth use`.\n5. Integrar resolucao de conta no runtime.\n6. Adicionar/atualizar testes.\n7. Atualizar docs PT-BR + EN.\n8. Validar manualmente com duas contas no mesmo provider.\n9. Implementar interface no TUI/app para troca de conta.\n10. Garantir `build` e `typecheck` verdes antes de merge.\n\n## 17. Exemplos de Uso Final\n\n```bash\n# login da conta corporativa\nopencode auth login\n# alias: work\n\n# login da conta pessoal\nopencode auth login\n# alias: personal\n\n# ver estado\nopencode auth list\n\n# trocar conta ativa\nopencode auth use openai personal\n\n# remover so a conta pessoal\nopencode auth logout\n```\n\n## 18. Open Questions\n\n1. Queremos incluir override por config ja na primeira entrega?\n2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal?\n3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`?\n", - "startedAt": "2026-02-13T00:07:16.540Z", - "model": "minimax-coding-plan/MiniMax-M2.5", - "agent": "opencode" -} \ No newline at end of file diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index ac5da60e862..d504983f03b 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -90,9 +90,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const resolveConfigured = () => { - if (!sync.data.config.model) return - const [providerID, modelID] = sync.data.config.model.split("/") - const key = { providerID, modelID } + const configured = sync.data.config.model + if (!configured) return + const key = { providerID: configured.providerID, modelID: configured.id } if (isModelValid(key)) return key } diff --git a/packages/desktop/src/types/tauri-clipboard-manager.d.ts b/packages/desktop/src/types/tauri-clipboard-manager.d.ts new file mode 100644 index 00000000000..41f30aa61da --- /dev/null +++ b/packages/desktop/src/types/tauri-clipboard-manager.d.ts @@ -0,0 +1,11 @@ +declare module "@tauri-apps/plugin-clipboard-manager" { + type ClipboardImage = { + rgba(): Promise + size(): Promise<{ + width: number + height: number + }> + } + + export function readImage(): Promise +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 70457c8dbd3..972dd4d4f7d 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -114,7 +114,7 @@ export namespace Auth { /** * Get all accounts for a provider. */ - export async function getAccounts(providerID: string): Promise> { + export async function getAccounts(providerID: string): Promise> { const store = await load() const provider = store[providerID] return provider?.accounts ?? {} diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 2fb1be82d42..07663017869 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -188,7 +188,7 @@ export const AuthListCommand = cmd({ const name = database[providerID]?.name || providerID // Show provider name - prompts.log.info(`${UI.Style.TEXT_BOLD}${name}`) + prompts.log.info(`${UI.Style.TEXT_NORMAL_BOLD}${name}`) // Show all accounts for this provider if (providerData.accounts) { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 9682bee4ead..ec4ecec70b2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -13,6 +13,7 @@ import { DialogModel } from "./dialog-model" import { useKeyboard } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" +import { Provider } from "@/provider/provider" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -40,6 +41,29 @@ export function createDialogProviderOptions() { }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", async onSelect() { + const connected = sync.data.provider_next.connected ?? [] + const isConnected = connected.includes(provider.id) + + if (isConnected) { + const action = await new Promise((resolve) => { + dialog.replace(() => ( + resolve(opt.value)} + /> + )) + }) + + if (action === "switch") { + await dialog.replace(() => ) + return + } + } + const methods = sync.data.provider_auth[provider.id] ?? [ { type: "api", @@ -92,6 +116,51 @@ export function createDialogProviderOptions() { return options } +interface SwitchAccountDialogProps { + providerID: string + providerName: string +} + +function SwitchAccountDialog(props: SwitchAccountDialogProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + const { theme } = useTheme() + + const accounts = createMemo(() => { + const provider = sync.data.provider.find((p) => p.id === props.providerID) + if (!provider) return [] + return Object.entries(provider.models).map(([modelId, model]) => ({ + id: modelId, + name: model.name ?? modelId, + })) + }) + + const activeAccount = createMemo(() => { + const current = sync.data.config.model + if (current && current.providerID === props.providerID) { + return current.id + } + return sync.data.provider_default[props.providerID] + }) + + return ( + ({ + title: account.name, + value: account.id, + description: account.id === activeAccount() ? "Active" : undefined, + }))} + onSelect={async (option) => { + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.clear() + }} + /> + ) +} + export function DialogProvider() { const options = createDialogProviderOptions() return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1a299632643..e584f1871a8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -76,9 +76,12 @@ export namespace Config { // 6) Inline config (OPENCODE_CONFIG_CONTENT) // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - process.env[value.key] = value.token + for (const [key, providerData] of Object.entries(auth)) { + const activeAccountId = providerData.activeAccount + if (!activeAccountId || !providerData.accounts[activeAccountId]) continue + const account = providerData.accounts[activeAccountId] + if (account.type === "wellknown") { + process.env[account.key] = account.token log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) const response = await fetch(`${key}/.well-known/opencode`) if (!response.ok) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d76cc902ae6..f4a1ae80fbc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -848,12 +848,16 @@ export namespace Provider { } // load apikeys - for (const [providerID, provider] of Object.entries(await Auth.all())) { + for (const [providerID, providerData] of Object.entries(await Auth.all())) { if (disabled.has(providerID)) continue - if (provider.type === "api") { + const accounts = providerData.accounts + const activeAccountId = providerData.activeAccount + if (!activeAccountId || !accounts[activeAccountId]) continue + const account = accounts[activeAccountId] + if (account.type === "api") { mergeProvider(providerID, { source: "api", - key: provider.key, + key: account.key, }) } } @@ -1241,7 +1245,14 @@ export namespace Provider { } } - export function parseModel(model: string) { + export function parseModel(model: string | { providerID: string; id: string }) { + if (typeof model !== "string") { + return { + providerID: model.providerID, + modelID: model.id, + } + } + const [providerID, ...rest] = model.split("/") return { providerID: providerID, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9fb5206551b..6da74005d91 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -130,6 +130,63 @@ export namespace Server { }), ) .route("/global", GlobalRoutes()) + .get( + "/auth", + describeRoute({ + summary: "List all auth accounts", + description: "Get all providers and their accounts", + operationId: "auth.list", + responses: { + 200: { + description: "All auth accounts", + content: { + "application/json": { + schema: resolver( + z.record( + z.string(), + z.object({ + accounts: z.record(z.string(), Auth.Info), + activeAccount: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Auth.all()) + }, + ) + .get( + "/auth/:providerID", + describeRoute({ + summary: "Get provider accounts", + description: "Get all accounts for a specific provider", + operationId: "auth.getAccounts", + responses: { + 200: { + description: "Provider accounts", + content: { + "application/json": { + schema: resolver(z.record(z.string(), Auth.Info)), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + return c.json(await Auth.getAccounts(providerID)) + }, + ) .put( "/auth/:providerID", describeRoute({ @@ -192,6 +249,43 @@ export namespace Server { return c.json(true) }, ) + .post( + "/auth/:providerID/use", + describeRoute({ + summary: "Switch active account", + description: "Switch the active account for a provider", + operationId: "auth.use", + responses: { + 200: { + description: "Successfully switched account", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + validator( + "json", + z.object({ + account: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { account } = c.req.valid("json") + await Auth.use(providerID, account) + return c.json(true) + }, + ) .use(async (c, next) => { if (c.req.path === "/log") return next() const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 91b87f6498c..616bf8936e3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1471,9 +1471,14 @@ test("project config overrides remote well-known config", async () => { Auth.all = mock(() => Promise.resolve({ "https://example.com": { - type: "wellknown" as const, - key: "TEST_TOKEN", - token: "test-token", + accounts: { + default: { + type: "wellknown" as const, + key: "TEST_TOKEN", + token: "test-token", + }, + }, + activeAccount: "default", }, }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index af79c44a17a..310edddd37e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -9,10 +9,14 @@ import type { AppLogResponses, AppSkillsResponses, Auth as Auth3, + AuthGetAccountsResponses, + AuthListResponses, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + AuthUseErrors, + AuthUseResponses, CommandListResponses, Config as Config3, ConfigGetResponses, @@ -301,6 +305,15 @@ export class Global extends HeyApiClient { } export class Auth extends HeyApiClient { + /** + * List all auth accounts + * + * Get all providers and their accounts + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ url: "/auth", ...options }) + } + /** * Remove auth credentials * @@ -320,6 +333,25 @@ export class Auth extends HeyApiClient { }) } + /** + * Get provider accounts + * + * Get all accounts for a specific provider + */ + public getAccounts( + parameters: { + providerID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) + return (options?.client ?? this.client).get({ + url: "/auth/{providerID}", + ...options, + ...params, + }) + } + /** * Set auth credentials * @@ -354,6 +386,41 @@ export class Auth extends HeyApiClient { }, }) } + + /** + * Switch active account + * + * Switch the active account for a provider + */ + public use( + parameters: { + providerID: string + account?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "body", key: "account" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/auth/{providerID}/use", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Project extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b22b7e9af4e..8d8bdc82afa 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1430,7 +1430,7 @@ export type PermissionConfig = | PermissionActionConfig export type AgentConfig = { - model?: string + model?: Model /** * Default model variant for this agent (applies only when using the agent's configured model). */ @@ -1472,6 +1472,7 @@ export type AgentConfig = { permission?: PermissionConfig [key: string]: | unknown + | Model | string | number | { @@ -1702,7 +1703,7 @@ export type Config = { template: string description?: string agent?: string - model?: string + model?: Model subtask?: boolean } } @@ -1744,14 +1745,8 @@ export type Config = { * When set, ONLY these providers will be enabled. All other providers will be ignored */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ - model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ - small_model?: string + model?: Model + small_model?: Model /** * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. */ @@ -1787,6 +1782,12 @@ export type Config = { provider?: { [key: string]: ProviderConfig } + /** + * Account to use per provider. Use provider ID as key and account name as value (e.g., { "openai": "work", "anthropic": "personal" }) + */ + auth?: { + [key: string]: string + } /** * MCP (Model Context Protocol) server configurations */ @@ -1898,11 +1899,13 @@ export type OAuth = { expires: number accountId?: string enterpriseUrl?: string + email?: string } export type ApiAuth = { type: "api" key: string + email?: string } export type WellKnownAuth = { @@ -2329,6 +2332,29 @@ export type GlobalDisposeResponses = { export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] +export type AuthListData = { + body?: never + path?: never + query?: never + url: "/auth" +} + +export type AuthListResponses = { + /** + * All auth accounts + */ + 200: { + [key: string]: { + accounts: { + [key: string]: Auth + } + activeAccount?: string + } + } +} + +export type AuthListResponse = AuthListResponses[keyof AuthListResponses] + export type AuthRemoveData = { body?: never path: { @@ -2356,6 +2382,26 @@ export type AuthRemoveResponses = { export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] +export type AuthGetAccountsData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthGetAccountsResponses = { + /** + * Provider accounts + */ + 200: { + [key: string]: Auth + } +} + +export type AuthGetAccountsResponse = AuthGetAccountsResponses[keyof AuthGetAccountsResponses] + export type AuthSetData = { body?: Auth path: { @@ -2383,6 +2429,35 @@ export type AuthSetResponses = { export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type AuthUseData = { + body?: { + account: string + } + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}/use" +} + +export type AuthUseErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthUseError = AuthUseErrors[keyof AuthUseErrors] + +export type AuthUseResponses = { + /** + * Successfully switched account + */ + 200: boolean +} + +export type AuthUseResponse = AuthUseResponses[keyof AuthUseResponses] + export type ProjectListData = { body?: never path?: never diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index e739afc16d8..9cb132698ed 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,9 +1,8 @@ -import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { useWorkerPool } from "../context/worker-pool" export type SSRDiffProps = DiffProps & { @@ -25,21 +24,10 @@ export function Diff(props: SSRDiffProps) { const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff | undefined - let sharedVirtualizer: NonNullable> | undefined const cleanupFunctions: Array<() => void> = [] const getRoot = () => fileDiffRef?.shadowRoot ?? undefined - const getVirtualizer = () => { - if (sharedVirtualizer) return sharedVirtualizer.virtualizer - - const result = acquireVirtualizer(container) - if (!result) return - - sharedVirtualizer = result - return result.virtualizer - } - const applyScheme = () => { const scheme = document.documentElement.dataset.colorScheme if (scheme === "dark" || scheme === "light") { @@ -227,27 +215,14 @@ export function Diff(props: SSRDiffProps) { onCleanup(() => monitor.disconnect()) } - const virtualizer = getVirtualizer() - - fileDiffInstance = virtualizer - ? new VirtualizedFileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - virtualizer, - virtualMetrics, - workerPool, - ) - : new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - workerPool, - ) + fileDiffInstance = new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...props.preloadedDiff, + }, + workerPool, + ) // @ts-expect-error - fileContainer is private but needed for SSR hydration fileDiffInstance.fileContainer = fileDiffRef fileDiffInstance.hydrate({ @@ -301,8 +276,6 @@ export function Diff(props: SSRDiffProps) { // Clean up FileDiff event handlers and dispose SolidJS components fileDiffInstance?.cleanUp() cleanupFunctions.forEach((dispose) => dispose()) - sharedVirtualizer?.release() - sharedVirtualizer = undefined }) return ( diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 0966db75e03..8e4fd64fbe4 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,9 +1,8 @@ import { checksum } from "@opencode-ai/util/encode" -import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" +import { FileDiff, type SelectedLineRange } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { getWorkerPool } from "../pierre/worker" type SelectionSide = "additions" | "deletions" @@ -53,7 +52,6 @@ function findSide(node: Node | null): SelectionSide | undefined { export function Diff(props: DiffProps) { let container!: HTMLDivElement let observer: MutationObserver | undefined - let sharedVirtualizer: NonNullable> | undefined let renderToken = 0 let selectionFrame: number | undefined let dragFrame: number | undefined @@ -94,16 +92,6 @@ export function Diff(props: DiffProps) { const [current, setCurrent] = createSignal | undefined>(undefined) const [rendered, setRendered] = createSignal(0) - const getVirtualizer = () => { - if (sharedVirtualizer) return sharedVirtualizer.virtualizer - - const result = acquireVirtualizer(container) - if (!result) return - - sharedVirtualizer = result - return result.virtualizer - } - const getRoot = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -529,15 +517,12 @@ export function Diff(props: DiffProps) { createEffect(() => { const opts = options() const workerPool = getWorkerPool(props.diffStyle) - const virtualizer = getVirtualizer() const annotations = local.annotations const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" instance?.cleanUp() - instance = virtualizer - ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) - : new FileDiff(opts, workerPool) + instance = new FileDiff(opts, workerPool) setCurrent(instance) container.innerHTML = "" @@ -624,8 +609,6 @@ export function Diff(props: DiffProps) { instance?.cleanUp() setCurrent(undefined) - sharedVirtualizer?.release() - sharedVirtualizer = undefined }) return
diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index dc9d857bf87..e47433ce301 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -136,7 +136,7 @@ export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) lineHoverHighlight: "both", disableBackground: false, expansionLineCount: 20, - hunkSeparators: "line-info-basic", + hunkSeparators: "line-info", lineDiffType: style === "split" ? "word-alt" : "none", maxLineDiffLength: 1000, maxLineLengthForHighlighting: 1000, diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 4957afc1255..8b3d4ea077a 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -1,76 +1,15 @@ -import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs" - -type Target = { - key: Document | HTMLElement - root: Document | HTMLElement - content: HTMLElement | undefined -} - -type Entry = { - virtualizer: Virtualizer - refs: number +type VirtualMetrics = { + lineHeight: number + hunkSeparatorHeight: number + fileGap: number } -const cache = new WeakMap() - -export const virtualMetrics: Partial = { +export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, fileGap: 0, } -function target(container: HTMLElement): Target | undefined { - if (typeof document === "undefined") return - - const root = container.closest("[data-component='session-review']") - if (root instanceof HTMLElement) { - const content = root.querySelector("[data-slot='session-review-container']") - return { - key: root, - root, - content: content instanceof HTMLElement ? content : undefined, - } - } - - return { - key: document, - root: document, - content: undefined, - } -} - -export function acquireVirtualizer(container: HTMLElement) { - const resolved = target(container) - if (!resolved) return - - let entry = cache.get(resolved.key) - if (!entry) { - const virtualizer = new Virtualizer() - virtualizer.setup(resolved.root, resolved.content) - entry = { - virtualizer, - refs: 0, - } - cache.set(resolved.key, entry) - } - - entry.refs += 1 - let done = false - - return { - virtualizer: entry.virtualizer, - release() { - if (done) return - done = true - - const current = cache.get(resolved.key) - if (!current) return - - current.refs -= 1 - if (current.refs > 0) return - - current.virtualizer.cleanUp() - cache.delete(resolved.key) - }, - } +export function acquireVirtualizer(_container: HTMLElement) { + return } diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index 1993ad7aa6f..0d117c3683f 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -21,7 +21,6 @@ function createPool(lineDiffType: "none" | "word-alt") { { theme: "OpenCode", lineDiffType, - preferredHighlighter: "shiki-wasm", }, ) From 0b0f8376d17e3deb05d87447cecd77c447d594db Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 22:15:33 -0300 Subject: [PATCH 11/12] chore: remove ralph loop stuff --- .ralph/ralph-opencode.config.json | 20 -- prompt.md | 400 ------------------------------ 2 files changed, 420 deletions(-) delete mode 100644 .ralph/ralph-opencode.config.json delete mode 100644 prompt.md diff --git a/.ralph/ralph-opencode.config.json b/.ralph/ralph-opencode.config.json deleted file mode 100644 index f23c3f91c86..00000000000 --- a/.ralph/ralph-opencode.config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "read": "allow", - "edit": "allow", - "glob": "allow", - "grep": "allow", - "list": "allow", - "bash": "allow", - "task": "allow", - "webfetch": "allow", - "websearch": "allow", - "codesearch": "allow", - "todowrite": "allow", - "todoread": "allow", - "question": "allow", - "lsp": "allow", - "external_directory": "allow" - } -} \ No newline at end of file diff --git a/prompt.md b/prompt.md deleted file mode 100644 index 108d2be04d4..00000000000 --- a/prompt.md +++ /dev/null @@ -1,400 +0,0 @@ - -# Plano Completo: Multi-Auth para OpenCode - -## 1. Objetivo - -Permitir multiplas contas por provider no OpenCode (exemplo: `openai/work` e `openai/personal`) com: - -- troca rapida de conta ativa -- selecao explicita por projeto/sessao quando necessario -- compatibilidade total com formato legado de credenciais -- zero regressao para usuarios com uma unica conta - -## 2. Problema Atual - -Hoje o armazenamento de auth eh indexado por `providerID` com apenas uma credencial por provider. -Isso impede cenarios comuns: - -- separar conta pessoal e conta corporativa no mesmo provider -- alternar billing sem relogar -- padronizar por projeto (repo A usa `work`, repo B usa `personal`) - -## 3. Escopo - -### Em escopo - -- novo modelo de dados para auth com contas nomeadas -- comandos CLI para gerenciar contas -- interface no TUI/app para trocar conta ativa sem depender apenas de CLI -- selecao de conta no runtime com regras de precedencia -- migracao compativel com auth legado -- testes unitarios e de integracao de fluxo principal -- documentacao de uso e troubleshooting - -### Fora de escopo (fase inicial) - -- sincronizacao cloud de multiplas contas -- politicas de equipe via servidor remoto (pode vir na fase 2) - -## 4. Requisitos Funcionais - -1. Cadastrar varias contas para o mesmo provider. -2. Definir conta default por provider. -3. Trocar conta default sem novo login. -4. Selecionar conta por projeto/sessao via config/env. -5. Listar contas e visualizar qual esta ativa. -6. Remover conta especifica sem apagar as demais. -7. Remover provider inteiro quando desejado. -8. Ler auth legado sem erro. -9. Ter interface no TUI/app para selecionar e trocar conta ativa por provider. - -## 5. Requisitos Nao Funcionais - -1. Backward compatibility total. -2. Armazenamento local com permissao `0600`, igual ao comportamento atual. -3. Sem uso de `any`. -4. Sem degradar tempo de inicializacao de forma perceptivel. -5. Cobertura de testes para paths criticos (legacy + novo). -6. Build e typecheck devem passar ao final da implementacao. - -## 6. Arquitetura Proposta - -### 6.1 Modelo de dados (auth.json v2) - -Formato proposto: - -```json -{ - "openai": { - "default": "work", - "accounts": { - "work": { - "type": "oauth", - "access": "...", - "refresh": "...", - "expires": 1769999999, - "accountId": "org_work" - }, - "personal": { - "type": "api", - "key": "sk-..." - } - } - }, - "anthropic": { - "default": "default", - "accounts": { - "default": { - "type": "api", - "key": "..." - } - } - } -} -``` - -### 6.2 Compatibilidade com legado - -Formato legado atual: - -```json -{ - "openai": { "type": "api", "key": "..." } -} -``` - -Regra: - -- leitura: se valor de provider estiver no formato legado, tratar em memoria como: - - `default = "default"` - - `accounts.default = ` -- escrita: qualquer mutacao salva no formato v2 - -### 6.3 Schema em codigo - -Arquivo principal: `packages/opencode/src/auth/index.ts` - -Adicionar: - -- `Auth.Accounts` (objeto com `default` e `accounts`) -- `Auth.Storage` (record provider -> Accounts) -- parser que aceite `Info | Accounts` no input e normalize para `Accounts` - -## 7. API Interna de Auth - -Evolucao da namespace `Auth`: - -1. `all(): Promise>` -2. `list(providerID?: string): Promise | Record>>` -3. `get(providerID: string, account?: string): Promise` -4. `set(providerID: string, info: Info, account?: string): Promise` -5. `use(providerID: string, account: string): Promise` -6. `remove(providerID: string, account?: string): Promise` -7. `default(providerID: string): Promise` - -Regras: - -- `account` default para `"default"` no `set` quando provider nao existir -- `set` em conta nova nao deve alterar default automaticamente se ja existir default -- `remove(provider, account)`: - - se remover conta ativa e houver outras, promover uma conta deterministica (ordem alfabetica) - - se remover ultima conta, remover provider inteiro - -## 8. Design CLI - -Arquivo: `packages/opencode/src/cli/cmd/auth.ts` - -### 8.1 `opencode auth login` - -Fluxo: - -1. selecionar provider (igual hoje) -2. executar fluxo de auth (oauth/api/plugin) -3. perguntar alias da conta: - - default sugestao: `default` - - validacao: `^[a-z0-9][a-z0-9-_]{0,31}$` -4. opcional: "usar como conta padrao agora?" (sim/nao) - -### 8.2 `opencode auth list` - -Exibir por provider: - -- provider name -- tipo de cada conta (`api`, `oauth`, `wellknown`) -- marcador da conta ativa (`*`) -- metadados relevantes quando existirem (`accountId`) - -Exemplo: - -```txt -OpenAI - * work oauth accountId=org_work - personal api -``` - -### 8.3 `opencode auth use` - -Novo comando: - -```bash -opencode auth use -opencode auth use openai personal -``` - -Com argumento opcional para modo nao interativo. -Sem argumentos, abre prompt provider -> conta. - -### 8.4 `opencode auth logout` - -Ajustar para permitir: - -- remover conta especifica -- remover provider inteiro (acao explicita) - -Fluxo recomendado: - -1. selecionar provider -2. selecionar conta ou "all accounts" -3. confirmar quando for "all accounts" - -### 8.5 Interface obrigatoria para troca de conta (TUI/app) - -Objetivo: permitir troca de conta ativa sem sair da interface principal. - -Requisitos minimos: - -1. Expor acao no TUI (slash command ou dialog de provider) para "Switch account". -2. Listar contas por provider com marcador da conta ativa. -3. Permitir trocar conta com confirmacao visual imediata. -4. Reutilizar backend de `Auth.use` para manter consistencia com a CLI. -5. Cobrir fluxo com teste de integracao (quando aplicavel ao modulo). - -## 9. Resolucao de Conta no Runtime - -Arquivos alvo: - -- `packages/opencode/src/provider/auth.ts` -- `packages/opencode/src/config/config.ts` -- pontos de chamada que leem credencial para provider - -### 9.1 Precedencia de resolucao - -1. override explicito da chamada (quando existir) -2. config de projeto (`opencode.json`) para conta do provider -3. env var dedicada (opcional na fase 1, recomendado fase 1.5) -4. conta default do provider - -### 9.2 Chave de configuracao sugerida - -No `opencode.json`: - -```json -{ - "auth": { - "account": { - "openai": "work", - "anthropic": "personal" - } - } -} -``` - -Se essa chave for adicionada na fase 1, deve entrar no schema de config com docs. -Se ficar para fase 2, manter apenas default global via `auth use`. - -## 10. OAuth e Plugins - -Nos fluxos OAuth/plugin: - -- continuar salvando `accountId` quando o provider retornar esse campo -- associar resultado ao alias escolhido no CLI -- manter comportamento atual para providers que retornam `provider` custom no callback - -## 11. Migracao - -### 11.1 Estrategia - -- migracao lazy na leitura -- persistencia no novo formato na primeira escrita -- sem comando manual obrigatorio - -### 11.2 Integridade - -- parse robusto por item -- entradas invalidas sao ignoradas (como hoje), sem derrubar carga inteira -- escrita atomica via `Bun.write` - -### 11.3 Rollback - -Nao ha rollback automatico de arquivo. -Para seguranca operacional, documentar backup manual: - -```bash -cp ~/.local/share/opencode/auth.json ~/.local/share/opencode/auth.json.bak -``` - -## 12. Plano de Testes - -### 12.1 Unitarios (Auth core) - -Arquivo sugerido: `packages/opencode/test/auth/auth.test.ts` - -Cenarios: - -1. ler formato legado e normalizar corretamente -2. `set` cria provider e conta default -3. `set` adiciona segunda conta sem trocar default -4. `use` troca default para conta existente -5. `remove` conta nao default preserva default -6. `remove` conta default promove outra -7. `remove` ultima conta apaga provider -8. parser ignora entradas invalidas - -### 12.2 CLI integration - -Arquivos em `packages/opencode/test/...` - -1. `auth list` mostra marcador de default -2. `auth use` altera conta ativa -3. `auth logout` remove somente conta escolhida - -### 12.3 Runtime integration - -1. provider resolve conta por default -2. provider resolve conta por override de config -3. fallback legado continua funcional - -## 13. Riscos e Mitigacoes - -1. Risco: quebrar leitura de auth legado. - Mitigacao: parser dual + testes de fixture legado. - -2. Risco: confusao de UX com muitas opcoes. - Mitigacao: defaults fortes e fluxo interativo curto. - -3. Risco: conflito de nomes de conta. - Mitigacao: validacao e confirmacao de overwrite. - -4. Risco: providers com oauth diferente por tenant. - Mitigacao: preservar `accountId` e expor no `auth list`. - -## 14. Fases de Entrega - -### Fase 1 (core) - -- schema/auth storage v2 -- compat legado -- API interna (`get/set/use/remove/list`) -- testes unitarios de auth - -### Fase 2 (CLI) - -- `auth login` com alias -- `auth list` com contas -- `auth use` -- `auth logout` granular -- interface no TUI/app para troca de conta ativa -- testes de CLI - -### Fase 3 (runtime/config) - -- resolucao por conta ativa -- override por config/env (se aprovado no escopo) -- testes de integracao de provider - -### Fase 4 (docs e hardening) - -- docs de comandos novos -- troubleshooting multi-auth -- validacao final com cenarios work/personal - -## 15. Definicao de Pronto (DoD) - -1. Usuario consegue autenticar duas contas OpenAI no mesmo ambiente. -2. Usuario alterna conta ativa com um comando (`auth use`) sem relogar. -3. Sessao usa a conta esperada conforme regra de precedencia. -4. Usuarios legados nao precisam fazer nenhuma acao manual. -5. Suite de testes adicionada e passando nos modulos afetados. -6. Docs publicadas com exemplos reais. -7. Existe interface no TUI/app para trocar conta ativa por provider. -8. `typecheck` e `build` passam sem erros nos pacotes afetados. - -## 16. Checklist de Implementacao - -1. Atualizar `Auth` schema e normalizacao legado. -2. Implementar API interna multi-account. -3. Atualizar fluxos de `auth login/list/logout`. -4. Adicionar `auth use`. -5. Integrar resolucao de conta no runtime. -6. Adicionar/atualizar testes. -7. Atualizar docs PT-BR + EN. -8. Validar manualmente com duas contas no mesmo provider. -9. Implementar interface no TUI/app para troca de conta. -10. Garantir `build` e `typecheck` verdes antes de merge. - -## 17. Exemplos de Uso Final - -```bash -# login da conta corporativa -opencode auth login -# alias: work - -# login da conta pessoal -opencode auth login -# alias: personal - -# ver estado -opencode auth list - -# trocar conta ativa -opencode auth use openai personal - -# remover so a conta pessoal -opencode auth logout -``` - -## 18. Open Questions - -1. Queremos incluir override por config ja na primeira entrega? -2. Precisamos de suporte explicito por sessao (`--account`) na CLI principal? -3. Qual UX final preferimos para troca no TUI: comando dedicado (`/account`) ou dentro de `/connect`? From 2f7b30c64a530e0785008822dd3f11983738e1be Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 12 Feb 2026 22:24:11 -0300 Subject: [PATCH 12/12] fix: use auth accounts API in switch account dialog --- .../cli/cmd/tui/component/dialog-provider.tsx | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index ec4ecec70b2..aad9b08957c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -125,37 +125,63 @@ function SwitchAccountDialog(props: SwitchAccountDialogProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() - const { theme } = useTheme() + const toast = useToast() + const [accounts, setAccounts] = createSignal< + Array<{ + id: string + type: string + disabled: boolean + }> + >([]) + const [activeAccount, setActiveAccount] = createSignal(undefined) - const accounts = createMemo(() => { - const provider = sync.data.provider.find((p) => p.id === props.providerID) - if (!provider) return [] - return Object.entries(provider.models).map(([modelId, model]) => ({ - id: modelId, - name: model.name ?? modelId, - })) - }) + onMount(() => { + sdk.client.auth + .list() + .then((result) => { + const provider = result.data?.[props.providerID] + if (!provider) { + setAccounts([]) + setActiveAccount(undefined) + return + } - const activeAccount = createMemo(() => { - const current = sync.data.config.model - if (current && current.providerID === props.providerID) { - return current.id - } - return sync.data.provider_default[props.providerID] + setActiveAccount(provider.activeAccount) + setAccounts( + Object.entries(provider.accounts ?? {}).map(([id, info]) => ({ + id, + type: info.type, + disabled: (info as { disabled?: boolean }).disabled === true, + })), + ) + }) + .catch(toast.error) }) return ( ({ - title: account.name, + title: account.id, value: account.id, - description: account.id === activeAccount() ? "Active" : undefined, + description: account.id === activeAccount() ? "Active" : account.type, + footer: account.disabled ? "Disabled" : undefined, + disabled: account.disabled, }))} - onSelect={async (option) => { - await sdk.client.instance.dispose() - await sync.bootstrap() - dialog.clear() + current={activeAccount()} + onSelect={(option) => { + sdk.client.auth + .use( + { + providerID: props.providerID, + account: option.value, + }, + { throwOnError: true }, + ) + .then(() => sdk.client.instance.dispose()) + .then(() => sync.bootstrap()) + .then(() => dialog.clear()) + .catch(toast.error) }} /> )