feat(connect): unify provider connect flows across TUI and CLI (#1243)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
115
src/cli/commands/connect-normalize.ts
Normal file
115
src/cli/commands/connect-normalize.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
169
src/cli/commands/connect-oauth-core.ts
Normal file
169
src/cli/commands/connect-oauth-core.ts
Normal 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
306
src/cli/subcommands/connect.ts
Normal file
306
src/cli/subcommands/connect.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
57
src/tests/cli/connect-normalize.test.ts
Normal file
57
src/tests/cli/connect-normalize.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
137
src/tests/cli/connect-oauth-core.test.ts
Normal file
137
src/tests/cli/connect-oauth-core.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
94
src/tests/cli/connect-subcommand.test.ts
Normal file
94
src/tests/cli/connect-subcommand.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
9
src/tests/cli/subcommand-router-connect.test.ts
Normal file
9
src/tests/cli/subcommand-router-connect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user