feat(connect): unify provider connect flows across TUI and CLI (#1243)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-03 13:26:26 -08:00
committed by GitHub
parent be7606824b
commit d038695244
11 changed files with 1258 additions and 712 deletions

View File

@@ -7103,8 +7103,8 @@ export default function App({
return { submitted: true };
}
// /connect codex - direct OAuth flow (kept for backwards compatibility)
if (msg.trim().startsWith("/connect codex")) {
// /connect <provider> - direct CLI-style provider flow
if (msg.trim().startsWith("/connect ")) {
const cmd = commandRunner.start(msg, "Starting connection...");
const {
handleConnect,
@@ -13147,7 +13147,7 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
setActiveOverlay("model");
},
},
"/connect codex",
"/connect chatgpt",
);
} finally {
setActiveConnectCommandId(null);

View File

@@ -0,0 +1,115 @@
import {
type ByokProvider,
type ByokProviderId,
getProviderConfig,
} from "../../providers/byok-providers";
export type ConnectProviderCanonical =
| "chatgpt"
| "anthropic"
| "openai"
| "zai"
| "minimax"
| "gemini"
| "openrouter"
| "bedrock";
const ALIAS_TO_CANONICAL: Record<string, ConnectProviderCanonical> = {
chatgpt: "chatgpt",
codex: "chatgpt",
anthropic: "anthropic",
openai: "openai",
zai: "zai",
minimax: "minimax",
gemini: "gemini",
openrouter: "openrouter",
bedrock: "bedrock",
};
const CANONICAL_ORDER: ConnectProviderCanonical[] = [
"chatgpt",
"anthropic",
"openai",
"zai",
"minimax",
"gemini",
"openrouter",
"bedrock",
];
function canonicalToByokId(
canonical: ConnectProviderCanonical,
): ByokProviderId {
return canonical === "chatgpt" ? "codex" : canonical;
}
export interface ResolvedConnectProvider {
rawInput: string;
canonical: ConnectProviderCanonical;
byokId: ByokProviderId;
byokProvider: ByokProvider;
}
export function resolveConnectProvider(
providerToken: string | undefined,
): ResolvedConnectProvider | null {
if (!providerToken) {
return null;
}
const rawInput = providerToken.trim().toLowerCase();
if (!rawInput) {
return null;
}
const canonical = ALIAS_TO_CANONICAL[rawInput];
if (!canonical) {
return null;
}
const byokId = canonicalToByokId(canonical);
const byokProvider = getProviderConfig(byokId);
if (!byokProvider) {
return null;
}
return {
rawInput,
canonical,
byokId,
byokProvider,
};
}
export function listConnectProvidersForHelp(): string[] {
return CANONICAL_ORDER.map((provider) => {
if (provider === "chatgpt") {
return "chatgpt (alias: codex)";
}
return provider;
});
}
export function listConnectProviderTokens(): string[] {
return [...CANONICAL_ORDER, "codex"];
}
export function isConnectOAuthProvider(
provider: ResolvedConnectProvider,
): boolean {
return provider.canonical === "chatgpt";
}
export function isConnectBedrockProvider(
provider: ResolvedConnectProvider,
): boolean {
return provider.canonical === "bedrock";
}
export function isConnectApiKeyProvider(
provider: ResolvedConnectProvider,
): boolean {
return (
!isConnectOAuthProvider(provider) && !isConnectBedrockProvider(provider)
);
}

View File

@@ -0,0 +1,169 @@
import {
exchangeCodeForTokens,
extractAccountIdFromToken,
OPENAI_OAUTH_CONFIG,
type OpenAITokens,
startLocalOAuthServer,
startOpenAIOAuth,
} from "../../auth/openai-oauth";
import {
type ChatGPTOAuthConfig,
createOrUpdateOpenAICodexProvider,
getOpenAICodexProvider,
OPENAI_CODEX_PROVIDER_NAME,
} from "../../providers/openai-codex-provider";
import { settingsManager } from "../../settings-manager";
interface OAuthCodeServerResult {
result: {
code: string;
state: string;
};
server: {
close: () => void;
};
}
interface OAuthStartResult {
authorizationUrl: string;
state: string;
codeVerifier: string;
redirectUri: string;
}
interface OAuthFlowDeps {
startOAuth: (port?: number) => Promise<OAuthStartResult>;
startCallbackServer: (
expectedState: string,
port?: number,
) => Promise<OAuthCodeServerResult>;
exchangeTokens: (
code: string,
codeVerifier: string,
redirectUri: string,
) => Promise<OpenAITokens>;
extractAccountId: (accessToken: string) => string;
createOrUpdateProvider: (config: ChatGPTOAuthConfig) => Promise<unknown>;
getProvider: () => Promise<unknown>;
storeOAuthState: typeof settingsManager.storeOAuthState;
clearOAuthState: typeof settingsManager.clearOAuthState;
}
const DEFAULT_DEPS: OAuthFlowDeps = {
startOAuth: (port?: number) =>
startOpenAIOAuth(
(port as typeof OPENAI_OAUTH_CONFIG.defaultPort | undefined) ??
OPENAI_OAUTH_CONFIG.defaultPort,
),
startCallbackServer: (expectedState: string, port?: number) =>
startLocalOAuthServer(
expectedState,
(port as typeof OPENAI_OAUTH_CONFIG.defaultPort | undefined) ??
OPENAI_OAUTH_CONFIG.defaultPort,
),
exchangeTokens: exchangeCodeForTokens,
extractAccountId: extractAccountIdFromToken,
createOrUpdateProvider: createOrUpdateOpenAICodexProvider,
getProvider: getOpenAICodexProvider,
storeOAuthState: (...args) => settingsManager.storeOAuthState(...args),
clearOAuthState: () => settingsManager.clearOAuthState(),
};
export interface ChatGPTOAuthFlowCallbacks {
onStatus: (message: string) => void | Promise<void>;
openBrowser?: (authorizationUrl: string) => Promise<void>;
}
export async function openOAuthBrowser(
authorizationUrl: string,
): Promise<void> {
try {
const { default: open } = await import("open");
const subprocess = await open(authorizationUrl, { wait: false });
subprocess.on("error", () => {
// Ignore browser launch errors. The user can still open the URL manually.
});
} catch {
// Ignore browser launch failures. The user can still open the URL manually.
}
}
export async function isChatGPTOAuthConnected(
deps: Partial<OAuthFlowDeps> = {},
): Promise<boolean> {
const mergedDeps = { ...DEFAULT_DEPS, ...deps };
const existing = await mergedDeps.getProvider();
return Boolean(existing);
}
export async function runChatGPTOAuthConnectFlow(
callbacks: ChatGPTOAuthFlowCallbacks,
deps: Partial<OAuthFlowDeps> = {},
): Promise<{ providerName: string }> {
const mergedDeps = { ...DEFAULT_DEPS, ...deps };
const browserOpener = callbacks.openBrowser ?? openOAuthBrowser;
await callbacks.onStatus("Checking account eligibility...");
try {
await callbacks.onStatus(
"Starting OAuth flow...\nA browser window will open for authorization.",
);
const { authorizationUrl, state, codeVerifier, redirectUri } =
await mergedDeps.startOAuth(OPENAI_OAUTH_CONFIG.defaultPort);
mergedDeps.storeOAuthState(state, codeVerifier, redirectUri, "openai");
await callbacks.onStatus(
`Starting local OAuth server on port ${OPENAI_OAUTH_CONFIG.defaultPort}...\n\n` +
"Opening browser for authorization...\n" +
"If the browser doesn't open automatically, visit:\n\n" +
`${authorizationUrl}`,
);
const serverPromise = mergedDeps.startCallbackServer(
state,
OPENAI_OAUTH_CONFIG.defaultPort,
);
await browserOpener(authorizationUrl);
await callbacks.onStatus(
"Waiting for authorization...\n\n" +
"Please complete the sign-in process in your browser.\n" +
"The page will redirect automatically when done.\n\n" +
`If needed, visit:\n${authorizationUrl}`,
);
const { result, server } = await serverPromise;
server.close();
await callbacks.onStatus(
"Authorization received! Exchanging code for tokens...",
);
const tokens = await mergedDeps.exchangeTokens(
result.code,
codeVerifier,
redirectUri,
);
await callbacks.onStatus("Extracting account information...");
const accountId = mergedDeps.extractAccountId(tokens.access_token);
await callbacks.onStatus("Creating ChatGPT OAuth provider...");
await mergedDeps.createOrUpdateProvider({
access_token: tokens.access_token,
id_token: tokens.id_token,
refresh_token: tokens.refresh_token,
account_id: accountId,
expires_at: Date.now() + tokens.expires_in * 1000,
});
mergedDeps.clearOAuthState();
return { providerName: OPENAI_CODEX_PROVIDER_NAME };
} catch (error) {
mergedDeps.clearOAuthState();
throw error;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { parseArgs } from "node:util";
import {
checkProviderApiKey,
createOrUpdateProvider,
} from "../../providers/byok-providers";
import { getErrorMessage } from "../../utils/error";
import {
isConnectApiKeyProvider,
isConnectBedrockProvider,
isConnectOAuthProvider,
listConnectProvidersForHelp,
listConnectProviderTokens,
resolveConnectProvider,
} from "../commands/connect-normalize";
import {
type ChatGPTOAuthFlowCallbacks,
isChatGPTOAuthConnected,
runChatGPTOAuthConnectFlow,
} from "../commands/connect-oauth-core";
const CONNECT_OPTIONS = {
help: { type: "boolean", short: "h" },
"api-key": { type: "string" },
method: { type: "string" },
"access-key": { type: "string" },
"secret-key": { type: "string" },
region: { type: "string" },
profile: { type: "string" },
} as const;
interface ConnectSubcommandDeps {
stdout: (message: string) => void;
stderr: (message: string) => void;
isTTY: () => boolean;
promptSecret: (label: string) => Promise<string>;
checkProviderApiKey: (
providerType: string,
apiKey: string,
accessKey?: string,
region?: string,
profile?: string,
) => Promise<void>;
createOrUpdateProvider: (
providerType: string,
providerName: string,
apiKey: string,
accessKey?: string,
region?: string,
profile?: string,
) => Promise<unknown>;
isChatGPTOAuthConnected: () => Promise<boolean>;
runChatGPTOAuthConnectFlow: (
callbacks: ChatGPTOAuthFlowCallbacks,
) => Promise<unknown>;
}
function readStringOption(
value: string | boolean | (string | boolean)[] | undefined,
): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
const DEFAULT_DEPS: ConnectSubcommandDeps = {
stdout: (message) => console.log(message),
stderr: (message) => console.error(message),
isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
promptSecret: promptSecret,
checkProviderApiKey,
createOrUpdateProvider,
isChatGPTOAuthConnected,
runChatGPTOAuthConnectFlow,
};
function formatUsage(): string {
return [
"Usage:",
" letta connect <provider> [options]",
"",
"Providers:",
` ${listConnectProvidersForHelp().join("\n ")}`,
"",
"Examples:",
" letta connect chatgpt",
" letta connect codex",
" letta connect anthropic <api_key>",
" letta connect openai --api-key <api_key>",
" letta connect bedrock --method iam --access-key <id> --secret-key <key> --region <region>",
" letta connect bedrock --method profile --profile <name> --region <region>",
].join("\n");
}
function formatBedrockUsage(): string {
return [
"Usage: letta connect bedrock [--method iam|profile] [options]",
"",
"IAM method:",
" --method iam --access-key <id> --secret-key <key> --region <region>",
"",
"Profile method:",
" --method profile --profile <name> --region <region>",
].join("\n");
}
async function promptSecret(promptLabel: string): Promise<string> {
class MutedWritable extends Writable {
muted = false;
override _write(
chunk: Buffer | string,
encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): void {
if (!this.muted) {
process.stdout.write(chunk, encoding);
}
callback();
}
}
const mutedOutput = new MutedWritable();
const rl = createInterface({
input: process.stdin,
output: mutedOutput,
terminal: true,
});
try {
process.stdout.write(promptLabel);
mutedOutput.muted = true;
const answer = await rl.question("");
process.stdout.write("\n");
return answer.trim();
} finally {
mutedOutput.muted = false;
rl.close();
}
}
export async function runConnectSubcommand(
argv: string[],
deps: Partial<ConnectSubcommandDeps> = {},
): Promise<number> {
const io = { ...DEFAULT_DEPS, ...deps };
let parsed: ReturnType<typeof parseArgs>;
try {
parsed = parseArgs({
args: argv,
options: CONNECT_OPTIONS,
strict: true,
allowPositionals: true,
});
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
io.stdout(formatUsage());
return 1;
}
const [providerToken, ...restPositionals] = parsed.positionals;
if (parsed.values.help || !providerToken || providerToken === "help") {
io.stdout(formatUsage());
return 0;
}
const provider = resolveConnectProvider(providerToken);
if (!provider) {
io.stderr(
`Unknown provider: ${providerToken}. Supported providers: ${listConnectProviderTokens().join(", ")}`,
);
return 1;
}
if (isConnectOAuthProvider(provider)) {
try {
if (await io.isChatGPTOAuthConnected()) {
io.stdout(
"Already connected to ChatGPT via OAuth. Disconnect first if you want to re-authenticate.",
);
return 0;
}
await io.runChatGPTOAuthConnectFlow({
onStatus: (status) => io.stdout(status),
});
io.stdout("Successfully connected to ChatGPT OAuth.");
return 0;
} catch (error) {
io.stderr(`Failed to connect ChatGPT OAuth: ${getErrorMessage(error)}`);
return 1;
}
}
if (isConnectBedrockProvider(provider)) {
const method = (
readStringOption(parsed.values.method) ??
restPositionals[0] ??
""
).toLowerCase();
const accessKey = readStringOption(parsed.values["access-key"]) ?? "";
const secretKey = readStringOption(parsed.values["secret-key"]) ?? "";
const region = readStringOption(parsed.values.region) ?? "";
const profile = readStringOption(parsed.values.profile) ?? "";
if (!method || (method !== "iam" && method !== "profile")) {
io.stderr("Bedrock method must be `iam` or `profile`.");
io.stdout(formatBedrockUsage());
return 1;
}
if (method === "iam" && (!accessKey || !secretKey || !region)) {
io.stderr(
"Missing IAM fields. Required: --access-key, --secret-key, --region.",
);
io.stdout(formatBedrockUsage());
return 1;
}
if (method === "profile" && (!profile || !region)) {
io.stderr("Missing profile fields. Required: --profile and --region.");
io.stdout(formatBedrockUsage());
return 1;
}
try {
io.stdout("Validating AWS Bedrock credentials...");
await io.checkProviderApiKey(
provider.byokProvider.providerType,
method === "iam" ? secretKey : "",
method === "iam" ? accessKey : undefined,
region,
method === "profile" ? profile : undefined,
);
io.stdout("Saving provider...");
await io.createOrUpdateProvider(
provider.byokProvider.providerType,
provider.byokProvider.providerName,
method === "iam" ? secretKey : "",
method === "iam" ? accessKey : undefined,
region,
method === "profile" ? profile : undefined,
);
io.stdout(
`Connected ${provider.byokProvider.displayName} (${provider.byokProvider.providerName}).`,
);
return 0;
} catch (error) {
io.stderr(`Failed to connect bedrock: ${getErrorMessage(error)}`);
return 1;
}
}
if (isConnectApiKeyProvider(provider)) {
let apiKey =
readStringOption(parsed.values["api-key"]) ?? restPositionals[0] ?? "";
if (!apiKey) {
if (!io.isTTY()) {
io.stderr(
`Missing API key for ${provider.canonical}. Pass as positional arg or --api-key.`,
);
return 1;
}
apiKey = await io.promptSecret(
`${provider.byokProvider.displayName} API key: `,
);
}
if (!apiKey) {
io.stderr("API key cannot be empty.");
return 1;
}
try {
io.stdout(`Validating ${provider.byokProvider.displayName} API key...`);
await io.checkProviderApiKey(provider.byokProvider.providerType, apiKey);
io.stdout("Saving provider...");
await io.createOrUpdateProvider(
provider.byokProvider.providerType,
provider.byokProvider.providerName,
apiKey,
);
io.stdout(
`Connected ${provider.byokProvider.displayName} (${provider.byokProvider.providerName}).`,
);
return 0;
} catch (error) {
io.stderr(
`Failed to connect ${provider.byokProvider.displayName}: ${getErrorMessage(error)}`,
);
return 1;
}
}
io.stderr("Unsupported provider configuration.");
return 1;
}

View File

@@ -1,5 +1,6 @@
import { runAgentsSubcommand } from "./agents";
import { runBlocksSubcommand } from "./blocks";
import { runConnectSubcommand } from "./connect";
import { runListenSubcommand } from "./listen.tsx";
import { runMemfsSubcommand } from "./memfs";
import { runMessagesSubcommand } from "./messages";
@@ -22,6 +23,8 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
return runBlocksSubcommand(rest);
case "remote":
return runListenSubcommand(rest);
case "connect":
return runConnectSubcommand(rest);
default:
return null;
}

View File

@@ -78,6 +78,7 @@ USAGE
letta agents ... Agents subcommands (JSON-only)
letta messages ... Messages subcommands (JSON-only)
letta blocks ... Blocks subcommands (JSON-only)
letta connect ... Connect providers from terminal
OPTIONS
${renderCliOptionsHelp()}
@@ -96,6 +97,7 @@ SUBCOMMANDS (JSON-only)
letta blocks list --agent <id>
letta blocks copy --block-id <id> [--label <label>] [--agent <id>] [--override]
letta blocks attach --block-id <id> [--agent <id>] [--read-only] [--override]
letta connect <provider> [options]
BEHAVIOR
On startup, Letta Code checks for saved profiles:

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import {
isConnectApiKeyProvider,
isConnectBedrockProvider,
isConnectOAuthProvider,
listConnectProvidersForHelp,
resolveConnectProvider,
} from "../../cli/commands/connect-normalize";
describe("connect provider normalization", () => {
test("normalizes codex alias to chatgpt provider", () => {
const resolved = resolveConnectProvider("codex");
expect(resolved).not.toBeNull();
if (!resolved) {
throw new Error("Expected codex alias to resolve");
}
expect(resolved?.canonical).toBe("chatgpt");
expect(resolved?.byokId).toBe("codex");
expect(resolved?.byokProvider.providerName).toBe("chatgpt-plus-pro");
expect(isConnectOAuthProvider(resolved)).toBe(true);
});
test("resolves standard api-key providers", () => {
const anthropic = resolveConnectProvider("anthropic");
const openrouter = resolveConnectProvider("openrouter");
if (!anthropic || !openrouter) {
throw new Error("Expected anthropic and openrouter providers to resolve");
}
expect(anthropic?.canonical).toBe("anthropic");
expect(isConnectApiKeyProvider(anthropic)).toBe(true);
expect(openrouter?.canonical).toBe("openrouter");
expect(isConnectApiKeyProvider(openrouter)).toBe(true);
});
test("resolves bedrock as non-api-key provider", () => {
const bedrock = resolveConnectProvider("bedrock");
if (!bedrock) {
throw new Error("Expected bedrock provider to resolve");
}
expect(bedrock?.canonical).toBe("bedrock");
expect(isConnectBedrockProvider(bedrock)).toBe(true);
expect(isConnectApiKeyProvider(bedrock)).toBe(false);
});
test("returns null for unknown provider", () => {
expect(resolveConnectProvider("unknown-provider")).toBeNull();
});
test("help list contains chatgpt alias", () => {
expect(listConnectProvidersForHelp()).toContain("chatgpt (alias: codex)");
});
});

View File

@@ -0,0 +1,137 @@
import { describe, expect, mock, test } from "bun:test";
import {
isChatGPTOAuthConnected,
runChatGPTOAuthConnectFlow,
} from "../../cli/commands/connect-oauth-core";
describe("connect OAuth core", () => {
test("runs full OAuth flow and creates provider", async () => {
const startOAuth = mock(() =>
Promise.resolve({
authorizationUrl: "https://auth.openai.com/oauth/authorize?abc",
state: "state-123",
codeVerifier: "verifier-123",
redirectUri: "http://localhost:1455/auth/callback",
}),
);
const serverClose = mock(() => undefined);
const startCallbackServer = mock(() =>
Promise.resolve({
result: { code: "oauth-code", state: "state-123" },
server: { close: serverClose },
}),
);
const exchangeTokens = mock(() =>
Promise.resolve({
access_token: "access-token",
id_token: "id-token",
refresh_token: "refresh-token",
token_type: "Bearer",
expires_in: 3600,
}),
);
const extractAccountId = mock(() => "acct_123");
const createOrUpdateProvider = mock(() =>
Promise.resolve({ id: "provider-1" }),
);
const storeOAuthState = mock(() => undefined);
const clearOAuthState = mock(() => undefined);
const openBrowser = mock(() => Promise.resolve());
const statuses: string[] = [];
const result = await runChatGPTOAuthConnectFlow(
{
onStatus: (status) => {
statuses.push(status);
},
openBrowser,
},
{
startOAuth,
startCallbackServer,
exchangeTokens,
extractAccountId,
createOrUpdateProvider,
storeOAuthState,
clearOAuthState,
},
);
expect(result.providerName).toBe("chatgpt-plus-pro");
expect(startOAuth).toHaveBeenCalledTimes(1);
expect(startCallbackServer).toHaveBeenCalledTimes(1);
expect(exchangeTokens).toHaveBeenCalledWith(
"oauth-code",
"verifier-123",
"http://localhost:1455/auth/callback",
);
expect(extractAccountId).toHaveBeenCalledWith("access-token");
expect(createOrUpdateProvider).toHaveBeenCalledTimes(1);
expect(storeOAuthState).toHaveBeenCalledWith(
"state-123",
"verifier-123",
"http://localhost:1455/auth/callback",
"openai",
);
expect(clearOAuthState).toHaveBeenCalledTimes(1);
expect(openBrowser).toHaveBeenCalledWith(
"https://auth.openai.com/oauth/authorize?abc",
);
expect(serverClose).toHaveBeenCalledTimes(1);
expect(statuses.length).toBeGreaterThan(3);
});
test("clears OAuth state when flow fails", async () => {
const expectedError = new Error("token exchange failed");
const clearOAuthState = mock(() => undefined);
await expect(
runChatGPTOAuthConnectFlow(
{
onStatus: () => undefined,
openBrowser: () => Promise.resolve(),
},
{
startOAuth: () =>
Promise.resolve({
authorizationUrl: "https://auth.openai.com/oauth/authorize?abc",
state: "state-123",
codeVerifier: "verifier-123",
redirectUri: "http://localhost:1455/auth/callback",
}),
startCallbackServer: () =>
Promise.resolve({
result: { code: "oauth-code", state: "state-123" },
server: { close: () => undefined },
}),
exchangeTokens: () => Promise.reject(expectedError),
extractAccountId: () => "acct_123",
createOrUpdateProvider: () => Promise.resolve({ id: "provider-1" }),
storeOAuthState: () => undefined,
clearOAuthState,
},
),
).rejects.toThrow("token exchange failed");
expect(clearOAuthState).toHaveBeenCalledTimes(1);
});
test("isChatGPTOAuthConnected reflects provider presence", async () => {
expect(
await isChatGPTOAuthConnected({
getProvider: () => Promise.resolve(null),
}),
).toBe(false);
expect(
await isChatGPTOAuthConnected({
getProvider: () =>
Promise.resolve({
id: "provider-1",
name: "chatgpt-plus-pro",
provider_type: "chatgpt_oauth",
}),
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, mock, test } from "bun:test";
import { runConnectSubcommand } from "../../cli/subcommands/connect";
function createIoDeps() {
const stdout: string[] = [];
const stderr: string[] = [];
return {
stdout,
stderr,
deps: {
stdout: (message: string) => stdout.push(message),
stderr: (message: string) => stderr.push(message),
isTTY: () => true,
promptSecret: mock(() => Promise.resolve("prompted-key")),
checkProviderApiKey: mock(() => Promise.resolve()),
createOrUpdateProvider: mock(() => Promise.resolve({ id: "provider-1" })),
isChatGPTOAuthConnected: mock(() => Promise.resolve(false)),
runChatGPTOAuthConnectFlow: mock(() =>
Promise.resolve({ providerName: "chatgpt-plus-pro" }),
),
},
};
}
describe("connect subcommand", () => {
test("runs OAuth flow for codex alias", async () => {
const { stdout, deps } = createIoDeps();
const exitCode = await runConnectSubcommand(["codex"], deps);
expect(exitCode).toBe(0);
expect(deps.runChatGPTOAuthConnectFlow).toHaveBeenCalledTimes(1);
expect(stdout.join("\n")).toContain(
"Successfully connected to ChatGPT OAuth.",
);
});
test("connects API key provider from positional key", async () => {
const { deps } = createIoDeps();
const exitCode = await runConnectSubcommand(
["anthropic", "sk-ant-123"],
deps,
);
expect(exitCode).toBe(0);
expect(deps.checkProviderApiKey).toHaveBeenCalledWith(
"anthropic",
"sk-ant-123",
);
expect(deps.createOrUpdateProvider).toHaveBeenCalledWith(
"anthropic",
"lc-anthropic",
"sk-ant-123",
);
});
test("returns error for missing key in non-TTY mode", async () => {
const { stderr, deps } = createIoDeps();
const nonTtyDeps = { ...deps, isTTY: () => false };
const exitCode = await runConnectSubcommand(["openai"], nonTtyDeps);
expect(exitCode).toBe(1);
expect(stderr.join("\n")).toContain("Missing API key");
expect(nonTtyDeps.promptSecret).not.toHaveBeenCalled();
});
test("prompts for missing key in TTY mode", async () => {
const { deps } = createIoDeps();
const exitCode = await runConnectSubcommand(["gemini"], deps);
expect(exitCode).toBe(0);
expect(deps.promptSecret).toHaveBeenCalledTimes(1);
expect(deps.checkProviderApiKey).toHaveBeenCalledWith(
"google_ai",
"prompted-key",
);
});
test("validates bedrock iam required flags", async () => {
const { stderr, deps } = createIoDeps();
const exitCode = await runConnectSubcommand(
["bedrock", "--method", "iam", "--access-key", "AKIA123"],
deps,
);
expect(exitCode).toBe(1);
expect(stderr.join("\n")).toContain("Missing IAM fields");
});
});

View File

@@ -0,0 +1,9 @@
import { describe, expect, test } from "bun:test";
import { runSubcommand } from "../../cli/subcommands/router";
describe("subcommand router", () => {
test("routes connect subcommand", async () => {
const exitCode = await runSubcommand(["connect", "help"]);
expect(exitCode).toBe(0);
});
});