Sign in with letta cloud (#44)

Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
Shubham Naik
2025-10-31 14:47:21 -07:00
committed by GitHub
parent af5a2fa68a
commit e384672a5a
11 changed files with 555 additions and 23 deletions

View File

@@ -6,6 +6,7 @@
"dependencies": { "dependencies": {
"@letta-ai/letta-client": "1.0.0-alpha.10", "@letta-ai/letta-client": "1.0.0-alpha.10",
"ink-link": "^5.0.0", "ink-link": "^5.0.0",
"open": "^10.2.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@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=="], "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=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "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=="], "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=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "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=="], "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-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-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-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=="], "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=="], "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=="], "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=="], "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
@@ -164,6 +181,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],

View File

@@ -23,7 +23,8 @@
}, },
"dependencies": { "dependencies": {
"@letta-ai/letta-client": "1.0.0-alpha.10", "@letta-ai/letta-client": "1.0.0-alpha.10",
"ink-link": "^5.0.0" "ink-link": "^5.0.0",
"open": "^10.2.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@vscode/ripgrep": "^1.17.0" "@vscode/ripgrep": "^1.17.0"
@@ -31,15 +32,15 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/diff": "^8.0.0", "@types/diff": "^8.0.0",
"typescript": "^5.0.0",
"husky": "9.1.7",
"lint-staged": "16.2.4",
"diff": "^8.0.2", "diff": "^8.0.2",
"husky": "9.1.7",
"ink": "^5.0.0", "ink": "^5.0.0",
"ink-spinner": "^5.0.0", "ink-spinner": "^5.0.0",
"ink-text-input": "^5.0.0", "ink-text-input": "^5.0.0",
"lint-staged": "16.2.4",
"minimatch": "^10.0.3", "minimatch": "^10.0.3",
"react": "18.2.0" "react": "18.2.0",
"typescript": "^5.0.0"
}, },
"scripts": { "scripts": {
"lint": "bunx --bun @biomejs/biome@2.2.5 check src", "lint": "bunx --bun @biomejs/biome@2.2.5 check src",

View File

@@ -1,17 +1,45 @@
import Letta from "@letta-ai/letta-client"; import Letta from "@letta-ai/letta-client";
import { refreshAccessToken } from "../auth/oauth";
import { settingsManager } from "../settings-manager"; import { settingsManager } from "../settings-manager";
export async function getClient() { export async function getClient() {
const settings = settingsManager.getSettings(); const settings = settingsManager.getSettings();
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; let apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
if (!apiKey) {
console.error("Missing LETTA_API_KEY"); // Check if token is expired and refresh if needed
console.error( if (
"Set it via environment variable or add it to ~/.letta/settings.json:", !process.env.LETTA_API_KEY &&
); settings.tokenExpiresAt &&
console.error(' { "env": { "LETTA_API_KEY": "sk-let-..." } }'); settings.refreshToken
process.exit(1); ) {
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 = const baseURL =
@@ -19,6 +47,12 @@ export async function getClient() {
settings.env?.LETTA_BASE_URL || settings.env?.LETTA_BASE_URL ||
"https://api.letta.com"; "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 // Auto-cache: if env vars are set but not in settings, write them to settings
let needsUpdate = false; let needsUpdate = false;
const updatedEnv = { ...settings.env }; const updatedEnv = { ...settings.env };

172
src/auth/oauth.ts Normal file
View 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
View 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
View 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);
});
});
}

View File

@@ -615,6 +615,63 @@ export default function App({
return { submitted: true }; 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 // Special handling for /stream command - toggle and save
if (msg.trim() === "/stream") { if (msg.trim() === "/stream") {
const newValue = !tokenStreamingEnabled; const newValue = !tokenStreamingEnabled;
@@ -685,7 +742,7 @@ export default function App({
setCommandRunning(true); setCommandRunning(true);
try { try {
const client = getClient(); const client = await getClient();
await client.agents.messages.reset(agentId, { await client.agents.messages.reset(agentId, {
add_default_initial_messages: false, add_default_initial_messages: false,
}); });

View File

@@ -43,6 +43,13 @@ export const commands: Record<string, Command> = {
return "Clearing messages..."; return "Clearing messages...";
}, },
}, },
"/logout": {
desc: "Clear credentials and exit",
handler: () => {
// Handled specially in App.tsx to access settings manager
return "Clearing credentials...";
},
},
}; };
/** /**

View File

@@ -258,7 +258,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
{ toolName: string; args: string } { toolName: string; args: string }
>(); >();
const autoApprovalEmitted = new Set<string>(); const autoApprovalEmitted = new Set<string>();
let lastApprovalId: string | null = null; let _lastApprovalId: string | null = null;
for await (const chunk of stream) { for await (const chunk of stream) {
// Detect server conflict due to pending approval; handle it and retry // 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) { for (const toolCall of toolCalls) {
if (toolCall?.tool_call_id && toolCall?.name) { if (toolCall?.tool_call_id && toolCall?.name) {
const id = toolCall.tool_call_id; const id = toolCall.tool_call_id;
lastApprovalId = id; _lastApprovalId = id;
// Prefer the most complete args we have seen so far; concatenate deltas // Prefer the most complete args we have seen so far; concatenate deltas
const prev = approvalRequests.get(id); const prev = approvalRequests.get(id);

View File

@@ -36,6 +36,9 @@ OPTIONS
BEHAVIOR BEHAVIOR
By default, letta auto-resumes the last agent used in the current directory 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. (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 EXAMPLES
# when installed as an executable # when installed as an executable
@@ -43,6 +46,9 @@ EXAMPLES
letta --new # Force new agent letta --new # Force new agent
letta --agent agent_123 letta --agent agent_123
# inside the interactive session
/logout # Clear credentials and exit
# headless with JSON output (includes stats) # headless with JSON output (includes stats)
letta -p "hello" --output-format json letta -p "hello" --output-format json
@@ -66,6 +72,7 @@ async function main() {
// Parse command-line arguments (Bun-idiomatic approach using parseArgs) // Parse command-line arguments (Bun-idiomatic approach using parseArgs)
let values: Record<string, unknown>; let values: Record<string, unknown>;
let positionals: string[];
try { try {
const parsed = parseArgs({ const parsed = parseArgs({
args: process.argv, args: process.argv,
@@ -89,6 +96,7 @@ async function main() {
allowPositionals: true, allowPositionals: true,
}); });
values = parsed.values; values = parsed.values;
positionals = parsed.positionals;
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
// Improve error message for common mistakes // Improve error message for common mistakes
@@ -104,6 +112,9 @@ async function main() {
process.exit(1); process.exit(1);
} }
// Check for subcommands
const _command = positionals[2]; // First positional after node and script
// Handle help flag first // Handle help flag first
if (values.help) { if (values.help) {
printHelp(); printHelp();
@@ -123,15 +134,58 @@ async function main() {
const specifiedModel = (values.model as string | undefined) ?? undefined; const specifiedModel = (values.model as string | undefined) ?? undefined;
const isHeadless = values.prompt || values.run || !process.stdin.isTTY; 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; const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
if (!apiKey) { const baseURL =
console.error("Missing LETTA_API_KEY"); process.env.LETTA_BASE_URL ||
console.error( settings.env?.LETTA_BASE_URL ||
"Set it via environment variable or add it to ~/.letta/settings.json:", "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-..." } }'); console.log("Let's reconfigure your setup.\n");
process.exit(1); 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) // Set tool filter if provided (controls which tools are loaded)

View File

@@ -15,6 +15,9 @@ export interface Settings {
globalSharedBlockIds: Record<string, string>; globalSharedBlockIds: Record<string, string>;
permissions?: PermissionRules; permissions?: PermissionRules;
env?: Record<string, string>; env?: Record<string, string>;
// OAuth token management
refreshToken?: string;
tokenExpiresAt?: number; // Unix timestamp in milliseconds
} }
export interface ProjectSettings { export interface ProjectSettings {