Sign in with letta cloud (#44)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
21
bun.lock
21
bun.lock
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@letta-ai/letta-client": "1.0.0-alpha.10",
|
||||
"ink-link": "^5.0.0",
|
||||
"open": "^10.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
@@ -60,6 +61,8 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
@@ -82,6 +85,12 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
|
||||
|
||||
"default-browser-id": ["default-browser-id@5.0.0", "", {}, "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA=="],
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
@@ -116,12 +125,18 @@
|
||||
|
||||
"ink-text-input": ["ink-text-input@5.0.1", "", { "dependencies": { "chalk": "^5.2.0", "type-fest": "^3.6.1" }, "peerDependencies": { "ink": "^4.0.0", "react": "^18.0.0" } }, "sha512-crnsYJalG4EhneOFnr/q+Kzw1RgmXI2KsBaLFE6mpiIKxAtJLUnvygOF2IUKO8z4nwkSkveGRBMd81RoYdRSag=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
|
||||
"is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"lint-staged": ["lint-staged@16.2.4", "", { "dependencies": { "commander": "^14.0.1", "listr2": "^9.0.4", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg=="],
|
||||
@@ -146,6 +161,8 @@
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
@@ -164,6 +181,8 @@
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
@@ -198,6 +217,8 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
11
package.json
11
package.json
@@ -23,7 +23,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@letta-ai/letta-client": "1.0.0-alpha.10",
|
||||
"ink-link": "^5.0.0"
|
||||
"ink-link": "^5.0.0",
|
||||
"open": "^10.2.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@vscode/ripgrep": "^1.17.0"
|
||||
@@ -31,15 +32,15 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/diff": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.4",
|
||||
"diff": "^8.0.2",
|
||||
"husky": "9.1.7",
|
||||
"ink": "^5.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^5.0.0",
|
||||
"lint-staged": "16.2.4",
|
||||
"minimatch": "^10.0.3",
|
||||
"react": "18.2.0"
|
||||
"react": "18.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "bunx --bun @biomejs/biome@2.2.5 check src",
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
import { refreshAccessToken } from "../auth/oauth";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
|
||||
export async function getClient() {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("Missing LETTA_API_KEY");
|
||||
console.error(
|
||||
"Set it via environment variable or add it to ~/.letta/settings.json:",
|
||||
);
|
||||
console.error(' { "env": { "LETTA_API_KEY": "sk-let-..." } }');
|
||||
process.exit(1);
|
||||
let apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
||||
|
||||
// Check if token is expired and refresh if needed
|
||||
if (
|
||||
!process.env.LETTA_API_KEY &&
|
||||
settings.tokenExpiresAt &&
|
||||
settings.refreshToken
|
||||
) {
|
||||
const now = Date.now();
|
||||
const expiresAt = settings.tokenExpiresAt;
|
||||
|
||||
// Refresh if token expires within 5 minutes
|
||||
if (expiresAt - now < 5 * 60 * 1000) {
|
||||
try {
|
||||
console.error("Refreshing expired access token...");
|
||||
const tokens = await refreshAccessToken(settings.refreshToken);
|
||||
|
||||
// Update settings with new token
|
||||
const updatedEnv = { ...settings.env };
|
||||
updatedEnv.LETTA_API_KEY = tokens.access_token;
|
||||
|
||||
settingsManager.updateSettings({
|
||||
env: updatedEnv,
|
||||
refreshToken: tokens.refresh_token || settings.refreshToken,
|
||||
tokenExpiresAt: now + tokens.expires_in * 1000,
|
||||
});
|
||||
|
||||
apiKey = tokens.access_token;
|
||||
console.error("Access token refreshed successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh access token:", error);
|
||||
console.error("Please run 'letta login' to re-authenticate");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
@@ -19,6 +47,12 @@ export async function getClient() {
|
||||
settings.env?.LETTA_BASE_URL ||
|
||||
"https://api.letta.com";
|
||||
|
||||
if (!apiKey && baseURL === "https://api.letta.com") {
|
||||
console.error("Missing LETTA_API_KEY");
|
||||
console.error("Run 'letta setup' to configure authentication");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auto-cache: if env vars are set but not in settings, write them to settings
|
||||
let needsUpdate = false;
|
||||
const updatedEnv = { ...settings.env };
|
||||
|
||||
172
src/auth/oauth.ts
Normal file
172
src/auth/oauth.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for Letta Cloud authentication
|
||||
* Uses Device Code Flow for CLI authentication
|
||||
*/
|
||||
|
||||
export const OAUTH_CONFIG = {
|
||||
clientId: "ci-let-724dea7e98f4af6f8f370f4b1466200c",
|
||||
clientSecret: "", // Not needed for device code flow
|
||||
authBaseUrl: "https://app.letta.com",
|
||||
apiBaseUrl: "https://api.letta.com",
|
||||
} as const;
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device Code Flow - Step 1: Request device code
|
||||
*/
|
||||
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await fetch(
|
||||
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/device/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to request device code: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as DeviceCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device Code Flow - Step 2: Poll for token
|
||||
*/
|
||||
export async function pollForToken(
|
||||
deviceCode: string,
|
||||
interval: number = 5,
|
||||
expiresIn: number = 900,
|
||||
): Promise<TokenResponse> {
|
||||
const startTime = Date.now();
|
||||
const expiresInMs = expiresIn * 1000;
|
||||
let pollInterval = interval * 1000;
|
||||
|
||||
while (Date.now() - startTime < expiresInMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
device_code: deviceCode,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return result as TokenResponse;
|
||||
}
|
||||
|
||||
const error = result as OAuthError;
|
||||
|
||||
if (error.error === "authorization_pending") {
|
||||
// User hasn't authorized yet, keep polling
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.error === "slow_down") {
|
||||
// We're polling too fast, increase interval by 5 seconds
|
||||
pollInterval += 5000;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.error === "access_denied") {
|
||||
throw new Error("User denied authorization");
|
||||
}
|
||||
|
||||
if (error.error === "expired_token") {
|
||||
throw new Error("Device code expired");
|
||||
}
|
||||
|
||||
throw new Error(`OAuth error: ${error.error_description || error.error}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to poll for token: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for authorization (15 minutes)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to refresh access token: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as TokenResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credentials by checking health endpoint
|
||||
*/
|
||||
export async function validateCredentials(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
155
src/auth/setup-ui.tsx
Normal file
155
src/auth/setup-ui.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Ink UI components for OAuth setup flow
|
||||
*/
|
||||
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import { useState } from "react";
|
||||
import { asciiLogo } from "../cli/components/AsciiArt.ts";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
import { OAUTH_CONFIG, pollForToken, requestDeviceCode } from "./oauth";
|
||||
|
||||
type SetupMode = "menu" | "device-code" | "auth-code" | "self-host" | "done";
|
||||
|
||||
interface SetupUIProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function SetupUI({ onComplete }: SetupUIProps) {
|
||||
const [mode, setMode] = useState<SetupMode>("menu");
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_deviceCode, setDeviceCode] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState<string | null>(null);
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null);
|
||||
|
||||
const { exit } = useApp();
|
||||
|
||||
// Handle menu navigation
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (mode === "menu") {
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(2, prev + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedOption === 0) {
|
||||
// Login to Letta Cloud - start device code flow
|
||||
setMode("device-code");
|
||||
startDeviceCodeFlow();
|
||||
} else if (selectedOption === 1) {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: mode === "menu" },
|
||||
);
|
||||
|
||||
const startDeviceCodeFlow = async () => {
|
||||
try {
|
||||
const deviceData = await requestDeviceCode();
|
||||
setDeviceCode(deviceData.device_code);
|
||||
setUserCode(deviceData.user_code);
|
||||
setVerificationUri(deviceData.verification_uri_complete);
|
||||
|
||||
// Auto-open browser
|
||||
try {
|
||||
const { default: open } = await import("open");
|
||||
await open(deviceData.verification_uri_complete);
|
||||
} catch (openErr) {
|
||||
// If auto-open fails, user can still manually visit the URL
|
||||
console.error("Failed to auto-open browser:", openErr);
|
||||
}
|
||||
|
||||
// Start polling in background
|
||||
pollForToken(
|
||||
deviceData.device_code,
|
||||
deviceData.interval,
|
||||
deviceData.expires_in,
|
||||
)
|
||||
.then((tokens) => {
|
||||
// Save tokens
|
||||
const now = Date.now();
|
||||
settingsManager.updateSettings({
|
||||
env: {
|
||||
...settingsManager.getSettings().env,
|
||||
LETTA_API_KEY: tokens.access_token,
|
||||
LETTA_BASE_URL: OAUTH_CONFIG.apiBaseUrl,
|
||||
},
|
||||
refreshToken: tokens.refresh_token,
|
||||
tokenExpiresAt: now + tokens.expires_in * 1000,
|
||||
});
|
||||
setMode("done");
|
||||
setTimeout(() => onComplete(), 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "done") {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="green">✓ Setup complete!</Text>
|
||||
<Text dimColor>Starting Letta Code...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">✗ Error: {error}</Text>
|
||||
<Text dimColor>Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "device-code") {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text>{asciiLogo}</Text>
|
||||
<Text bold>Login to Letta Cloud</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Opening browser for authorization...</Text>
|
||||
<Text> </Text>
|
||||
<Text>
|
||||
Your authorization code:{" "}
|
||||
<Text color="yellow" bold>
|
||||
{userCode}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>URL: {verificationUri}</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Waiting for you to authorize in the browser...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Main menu
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text>{asciiLogo}</Text>
|
||||
<Text bold>Welcome to Letta Code!</Text>
|
||||
<Text> </Text>
|
||||
<Text>Please choose how you'd like to authenticate:</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color={selectedOption === 0 ? "cyan" : undefined}>
|
||||
{selectedOption === 0 ? "→" : " "} Login to Letta Cloud
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={selectedOption === 1 ? "cyan" : undefined}>
|
||||
{selectedOption === 1 ? "→" : " "} Exit
|
||||
</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Use ↑/↓ to navigate, Enter to select</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
28
src/auth/setup.ts
Normal file
28
src/auth/setup.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Setup flow handler - can be triggered via `letta setup` or automatically on first run
|
||||
*/
|
||||
|
||||
import { render } from "ink";
|
||||
import React from "react";
|
||||
import { SetupUI } from "./setup-ui";
|
||||
|
||||
/**
|
||||
* Run the setup flow
|
||||
* Returns a promise that resolves when setup is complete
|
||||
*/
|
||||
export async function runSetup(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const { waitUntilExit } = render(
|
||||
React.createElement(SetupUI, {
|
||||
onComplete: () => {
|
||||
resolve();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
waitUntilExit().catch((error) => {
|
||||
console.error("Setup failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -615,6 +615,63 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /logout command - clear credentials and exit
|
||||
if (msg.trim() === "/logout") {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Clearing credentials...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
const { settingsManager } = await import("../settings-manager");
|
||||
const currentSettings = settingsManager.getSettings();
|
||||
const newEnv = { ...currentSettings.env };
|
||||
delete newEnv.LETTA_API_KEY;
|
||||
delete newEnv.LETTA_BASE_URL;
|
||||
|
||||
settingsManager.updateSettings({
|
||||
env: newEnv,
|
||||
refreshToken: undefined,
|
||||
tokenExpiresAt: undefined,
|
||||
});
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output:
|
||||
"✓ Logged out successfully. Run 'letta' to re-authenticate.",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
refreshDerived();
|
||||
|
||||
// Exit after a brief delay to show the message
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
} catch (error) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
refreshDerived();
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /stream command - toggle and save
|
||||
if (msg.trim() === "/stream") {
|
||||
const newValue = !tokenStreamingEnabled;
|
||||
@@ -685,7 +742,7 @@ export default function App({
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
const client = getClient();
|
||||
const client = await getClient();
|
||||
await client.agents.messages.reset(agentId, {
|
||||
add_default_initial_messages: false,
|
||||
});
|
||||
|
||||
@@ -43,6 +43,13 @@ export const commands: Record<string, Command> = {
|
||||
return "Clearing messages...";
|
||||
},
|
||||
},
|
||||
"/logout": {
|
||||
desc: "Clear credentials and exit",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access settings manager
|
||||
return "Clearing credentials...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -258,7 +258,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
||||
{ toolName: string; args: string }
|
||||
>();
|
||||
const autoApprovalEmitted = new Set<string>();
|
||||
let lastApprovalId: string | null = null;
|
||||
let _lastApprovalId: string | null = null;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Detect server conflict due to pending approval; handle it and retry
|
||||
@@ -309,7 +309,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
||||
for (const toolCall of toolCalls) {
|
||||
if (toolCall?.tool_call_id && toolCall?.name) {
|
||||
const id = toolCall.tool_call_id;
|
||||
lastApprovalId = id;
|
||||
_lastApprovalId = id;
|
||||
|
||||
// Prefer the most complete args we have seen so far; concatenate deltas
|
||||
const prev = approvalRequests.get(id);
|
||||
|
||||
68
src/index.ts
68
src/index.ts
@@ -36,6 +36,9 @@ OPTIONS
|
||||
BEHAVIOR
|
||||
By default, letta auto-resumes the last agent used in the current directory
|
||||
(stored in .letta/settings.local.json). Use --new to force a new agent.
|
||||
|
||||
If no credentials are configured, you'll be prompted to authenticate via
|
||||
Letta Cloud OAuth on first run.
|
||||
|
||||
EXAMPLES
|
||||
# when installed as an executable
|
||||
@@ -43,6 +46,9 @@ EXAMPLES
|
||||
letta --new # Force new agent
|
||||
letta --agent agent_123
|
||||
|
||||
# inside the interactive session
|
||||
/logout # Clear credentials and exit
|
||||
|
||||
# headless with JSON output (includes stats)
|
||||
letta -p "hello" --output-format json
|
||||
|
||||
@@ -66,6 +72,7 @@ async function main() {
|
||||
|
||||
// Parse command-line arguments (Bun-idiomatic approach using parseArgs)
|
||||
let values: Record<string, unknown>;
|
||||
let positionals: string[];
|
||||
try {
|
||||
const parsed = parseArgs({
|
||||
args: process.argv,
|
||||
@@ -89,6 +96,7 @@ async function main() {
|
||||
allowPositionals: true,
|
||||
});
|
||||
values = parsed.values;
|
||||
positionals = parsed.positionals;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// Improve error message for common mistakes
|
||||
@@ -104,6 +112,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for subcommands
|
||||
const _command = positionals[2]; // First positional after node and script
|
||||
|
||||
// Handle help flag first
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
@@ -123,15 +134,58 @@ async function main() {
|
||||
const specifiedModel = (values.model as string | undefined) ?? undefined;
|
||||
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
|
||||
|
||||
// Validate API key early before any UI rendering
|
||||
// Check if API key is configured
|
||||
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("Missing LETTA_API_KEY");
|
||||
console.error(
|
||||
"Set it via environment variable or add it to ~/.letta/settings.json:",
|
||||
const baseURL =
|
||||
process.env.LETTA_BASE_URL ||
|
||||
settings.env?.LETTA_BASE_URL ||
|
||||
"https://api.letta.com";
|
||||
|
||||
if (!apiKey && baseURL === "https://api.letta.com") {
|
||||
// For headless mode, error out (assume automation context)
|
||||
if (isHeadless) {
|
||||
console.error("Missing LETTA_API_KEY");
|
||||
console.error("Run 'letta' in interactive mode to authenticate");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// For interactive mode, show setup flow
|
||||
console.log("No credentials found. Let's get you set up!\n");
|
||||
const { runSetup } = await import("./auth/setup");
|
||||
await runSetup();
|
||||
// After setup, restart main flow
|
||||
return main();
|
||||
}
|
||||
|
||||
// Validate credentials by checking health endpoint
|
||||
const { validateCredentials } = await import("./auth/oauth");
|
||||
const isValid = await validateCredentials(baseURL, apiKey);
|
||||
|
||||
if (!isValid) {
|
||||
// For headless mode, error out with helpful message
|
||||
if (isHeadless) {
|
||||
console.error("Failed to connect to Letta server");
|
||||
console.error(`Base URL: ${baseURL}`);
|
||||
console.error(
|
||||
"Your credentials may be invalid or the server may be unreachable.",
|
||||
);
|
||||
console.error(
|
||||
"Delete ~/.letta/settings.json then run 'letta' to re-authenticate",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// For interactive mode, show setup flow
|
||||
console.log("Failed to connect to Letta server.");
|
||||
console.log(`Base URL: ${baseURL}\n`);
|
||||
console.log(
|
||||
"Your credentials may be invalid or the server may be unreachable.",
|
||||
);
|
||||
console.error(' { "env": { "LETTA_API_KEY": "sk-let-..." } }');
|
||||
process.exit(1);
|
||||
console.log("Let's reconfigure your setup.\n");
|
||||
const { runSetup } = await import("./auth/setup");
|
||||
await runSetup();
|
||||
// After setup, restart main flow
|
||||
return main();
|
||||
}
|
||||
|
||||
// Set tool filter if provided (controls which tools are loaded)
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface Settings {
|
||||
globalSharedBlockIds: Record<string, string>;
|
||||
permissions?: PermissionRules;
|
||||
env?: Record<string, string>;
|
||||
// OAuth token management
|
||||
refreshToken?: string;
|
||||
tokenExpiresAt?: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
|
||||
Reference in New Issue
Block a user