diff --git a/bun.lock b/bun.lock index 8a8034a..e249908 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 46706a8..b9c4448 100644 --- a/package.json +++ b/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", diff --git a/src/agent/client.ts b/src/agent/client.ts index c0cbcda..0f90fe9 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -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 }; diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts new file mode 100644 index 0000000..960fbd1 --- /dev/null +++ b/src/auth/oauth.ts @@ -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 { + 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 { + 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 { + 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 { + try { + const response = await fetch(`${baseUrl}/v1/health`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + return response.ok; + } catch { + return false; + } +} diff --git a/src/auth/setup-ui.tsx b/src/auth/setup-ui.tsx new file mode 100644 index 0000000..9849c74 --- /dev/null +++ b/src/auth/setup-ui.tsx @@ -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("menu"); + const [selectedOption, setSelectedOption] = useState(0); + const [error, setError] = useState(null); + const [_deviceCode, setDeviceCode] = useState(null); + const [userCode, setUserCode] = useState(null); + const [verificationUri, setVerificationUri] = useState(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 ( + + ✓ Setup complete! + Starting Letta Code... + + ); + } + + if (error) { + return ( + + ✗ Error: {error} + Press Ctrl+C to exit + + ); + } + + if (mode === "device-code") { + return ( + + {asciiLogo} + Login to Letta Cloud + + Opening browser for authorization... + + + Your authorization code:{" "} + + {userCode} + + + URL: {verificationUri} + + Waiting for you to authorize in the browser... + + ); + } + + // Main menu + return ( + + {asciiLogo} + Welcome to Letta Code! + + Please choose how you'd like to authenticate: + + + + {selectedOption === 0 ? "→" : " "} Login to Letta Cloud + + + + + {selectedOption === 1 ? "→" : " "} Exit + + + + Use ↑/↓ to navigate, Enter to select + + ); +} diff --git a/src/auth/setup.ts b/src/auth/setup.ts new file mode 100644 index 0000000..9769a91 --- /dev/null +++ b/src/auth/setup.ts @@ -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 { + return new Promise((resolve) => { + const { waitUntilExit } = render( + React.createElement(SetupUI, { + onComplete: () => { + resolve(); + }, + }), + ); + + waitUntilExit().catch((error) => { + console.error("Setup failed:", error); + process.exit(1); + }); + }); +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 4cb924c..fbf049f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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, }); diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index de02a34..cacec0e 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -43,6 +43,13 @@ export const commands: Record = { return "Clearing messages..."; }, }, + "/logout": { + desc: "Clear credentials and exit", + handler: () => { + // Handled specially in App.tsx to access settings manager + return "Clearing credentials..."; + }, + }, }; /** diff --git a/src/headless.ts b/src/headless.ts index 8047f12..700924b 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -258,7 +258,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { { toolName: string; args: string } >(); const autoApprovalEmitted = new Set(); - 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); diff --git a/src/index.ts b/src/index.ts index 650b1bf..deebe97 100755 --- a/src/index.ts +++ b/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; + 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) diff --git a/src/settings-manager.ts b/src/settings-manager.ts index c9278ea..6ed8db4 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -15,6 +15,9 @@ export interface Settings { globalSharedBlockIds: Record; permissions?: PermissionRules; env?: Record; + // OAuth token management + refreshToken?: string; + tokenExpiresAt?: number; // Unix timestamp in milliseconds } export interface ProjectSettings {