feat(coding-agent): add OAuth authentication for Claude Pro/Max

- Add /login and /logout commands for OAuth flow
- OAuth tokens stored in ~/.pi/agent/oauth.json with 0600 permissions
- Auto-refresh tokens when expired (5min buffer)
- Priority: OAuth > ANTHROPIC_OAUTH_TOKEN env > ANTHROPIC_API_KEY env
- Fix model selector async loading and re-render
- Add bracketed paste support to Input component for long codes
- Update README.md with OAuth documentation
- Add implementation docs and testing checklist
This commit is contained in:
Mario Zechner 2025-11-18 17:33:33 +01:00
parent 387cc97bac
commit 587d7c39a4
17 changed files with 1632 additions and 76 deletions

View file

@ -520,7 +520,7 @@ export async function main(args: string[]) {
if (!initialModel) {
// 4. Try first available model with valid API key
// Prefer default model for each provider if available
const { models: availableModels, error } = getAvailableModels();
const { models: availableModels, error } = await getAvailableModels();
if (error) {
console.error(chalk.red(error));
@ -561,7 +561,7 @@ export async function main(args: string[]) {
// Non-interactive mode: validate API key exists
if (!isInteractive && initialModel) {
const apiKey = parsed.apiKey || getApiKeyForModel(initialModel);
const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
if (!apiKey) {
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
process.exit(1);
@ -589,7 +589,7 @@ export async function main(args: string[]) {
}
// Check if restored model exists and has a valid API key
const hasApiKey = restoredModel ? !!getApiKeyForModel(restoredModel) : false;
const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;
if (restoredModel && hasApiKey) {
initialModel = restoredModel;
@ -610,7 +610,7 @@ export async function main(args: string[]) {
// Ensure we have a valid model - use the same fallback logic
if (!initialModel) {
const { models: availableModels, error: availableError } = getAvailableModels();
const { models: availableModels, error: availableError } = await getAvailableModels();
if (availableError) {
console.error(chalk.red(availableError));
process.exit(1);
@ -673,7 +673,7 @@ export async function main(args: string[]) {
}
// Use model-specific key lookup
const key = getApiKeyForModel(currentModel);
const key = await getApiKeyForModel(currentModel);
if (!key) {
throw new Error(
`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ~/.pi/agent/models.json`,

View file

@ -4,6 +4,7 @@ import AjvModule from "ajv";
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { getOAuthToken } from "./oauth/index.js";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
@ -218,20 +219,30 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
/**
* Get API key for a model (checks custom providers first, then built-in)
* Now async to support OAuth token refresh
*/
export function getApiKeyForModel(model: Model<Api>): string | undefined {
export async function getApiKeyForModel(model: Model<Api>): Promise<string | undefined> {
// For custom providers, check their apiKey config
const customKeyConfig = customProviderApiKeys.get(model.provider);
if (customKeyConfig) {
return resolveApiKey(customKeyConfig);
}
// For Anthropic, check ANTHROPIC_OAUTH_KEY first
// For Anthropic, check OAuth first
if (model.provider === "anthropic") {
const oauthKey = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthKey) {
return oauthKey;
// 1. Check OAuth storage (auto-refresh if needed)
const oauthToken = await getOAuthToken("anthropic");
if (oauthToken) {
return oauthToken;
}
// 2. Check ANTHROPIC_OAUTH_TOKEN env var (manual OAuth token)
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv) {
return oauthEnv;
}
// 3. Fall back to ANTHROPIC_API_KEY env var
}
// For built-in providers, use getApiKey from @mariozechner/pi-ai
@ -242,17 +253,20 @@ export function getApiKeyForModel(model: Model<Api>): string | undefined {
* Get only models that have valid API keys available
* Returns { models, error } - either models array or error message
*/
export function getAvailableModels(): { models: Model<Api>[]; error: string | null } {
export async function getAvailableModels(): Promise<{ models: Model<Api>[]; error: string | null }> {
const { models: allModels, error } = loadAndMergeModels();
if (error) {
return { models: [], error };
}
const availableModels = allModels.filter((model) => {
const apiKey = getApiKeyForModel(model);
return !!apiKey;
});
const availableModels: Model<Api>[] = [];
for (const model of allModels) {
const apiKey = await getApiKeyForModel(model);
if (apiKey) {
availableModels.push(model);
}
}
return { models: availableModels, error: null };
}

View file

@ -0,0 +1,128 @@
import { createHash, randomBytes } from "crypto";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
const SCOPES = "org:create_api_key user:profile user:inference";
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Login with Anthropic OAuth (device code flow)
*/
export async function loginAnthropic(
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
const { verifier, challenge } = generatePKCE();
// Build authorization URL
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuthUrl(authUrl);
// Wait for user to paste authorization code (format: code#state)
const authCode = await onPromptCode();
const splits = authCode.split("#");
const code = splits[0];
const state = splits[1];
// Exchange code for tokens
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
state: state,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
// Save credentials
const credentials: OAuthCredentials = {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
saveOAuthCredentials("anthropic", credentials);
}
/**
* Refresh Anthropic OAuth token using refresh token
*/
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token refresh failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
return {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
}

View file

@ -0,0 +1,115 @@
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
import {
listOAuthProviders as listOAuthProvidersFromStorage,
loadOAuthCredentials,
type OAuthCredentials,
removeOAuthCredentials,
saveOAuthCredentials,
} from "./storage.js";
// Re-export for convenience
export { listOAuthProvidersFromStorage as listOAuthProviders };
export type SupportedOAuthProvider = "anthropic" | "github-copilot";
export interface OAuthProviderInfo {
id: SupportedOAuthProvider;
name: string;
available: boolean;
}
/**
* Get list of OAuth providers
*/
export function getOAuthProviders(): OAuthProviderInfo[] {
return [
{
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
available: true,
},
{
id: "github-copilot",
name: "GitHub Copilot (coming soon)",
available: false,
},
];
}
/**
* Login with OAuth provider
*/
export async function login(
provider: SupportedOAuthProvider,
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
switch (provider) {
case "anthropic":
await loginAnthropic(onAuthUrl, onPromptCode);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
}
/**
* Logout from OAuth provider
*/
export async function logout(provider: SupportedOAuthProvider): Promise<void> {
removeOAuthCredentials(provider);
}
/**
* Refresh OAuth token for provider
*/
export async function refreshToken(provider: SupportedOAuthProvider): Promise<string> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
throw new Error(`No OAuth credentials found for ${provider}`);
}
let newCredentials: OAuthCredentials;
switch (provider) {
case "anthropic":
newCredentials = await refreshAnthropicToken(credentials.refresh);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
// Save new credentials
saveOAuthCredentials(provider, newCredentials);
return newCredentials.access;
}
/**
* Get OAuth token for provider (auto-refreshes if expired)
*/
export async function getOAuthToken(provider: SupportedOAuthProvider): Promise<string | null> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
return null;
}
// Check if token is expired (with 5 min buffer already applied)
if (Date.now() >= credentials.expires) {
// Token expired - refresh it
try {
return await refreshToken(provider);
} catch (error) {
console.error(`Failed to refresh OAuth token for ${provider}:`, error);
// Remove invalid credentials
removeOAuthCredentials(provider);
return null;
}
}
return credentials.access;
}

View file

@ -0,0 +1,95 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
export interface OAuthCredentials {
type: "oauth";
refresh: string;
access: string;
expires: number;
}
interface OAuthStorageFormat {
[provider: string]: OAuthCredentials;
}
/**
* Get path to oauth.json
*/
function getOAuthFilePath(): string {
const configDir = join(homedir(), ".pi", "agent");
return join(configDir, "oauth.json");
}
/**
* Ensure the config directory exists
*/
function ensureConfigDir(): void {
const configDir = join(homedir(), ".pi", "agent");
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
}
/**
* Load all OAuth credentials from oauth.json
*/
function loadStorage(): OAuthStorageFormat {
const filePath = getOAuthFilePath();
if (!existsSync(filePath)) {
return {};
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error(`Warning: Failed to load OAuth credentials: ${error}`);
return {};
}
}
/**
* Save all OAuth credentials to oauth.json
*/
function saveStorage(storage: OAuthStorageFormat): void {
ensureConfigDir();
const filePath = getOAuthFilePath();
writeFileSync(filePath, JSON.stringify(storage, null, 2), "utf-8");
// Set permissions to owner read/write only
chmodSync(filePath, 0o600);
}
/**
* Load OAuth credentials for a specific provider
*/
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
const storage = loadStorage();
return storage[provider] || null;
}
/**
* Save OAuth credentials for a specific provider
*/
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
const storage = loadStorage();
storage[provider] = creds;
saveStorage(storage);
}
/**
* Remove OAuth credentials for a specific provider
*/
export function removeOAuthCredentials(provider: string): void {
const storage = loadStorage();
delete storage[provider];
saveStorage(storage);
}
/**
* List all providers with OAuth credentials
*/
export function listOAuthProviders(): string[] {
const storage = loadStorage();
return Object.keys(storage);
}

View file

@ -1,5 +1,5 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
@ -24,8 +24,10 @@ export class ModelSelectorComponent extends Container {
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage: string | null = null;
private tui: TUI;
constructor(
tui: TUI,
currentModel: Model<any> | null,
settingsManager: SettingsManager,
onSelect: (model: Model<any>) => void,
@ -33,14 +35,12 @@ export class ModelSelectorComponent extends Container {
) {
super();
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Load all models (fresh every time)
this.loadModels();
// Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new Spacer(1));
@ -72,13 +72,17 @@ export class ModelSelectorComponent extends Container {
// Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
// Initial render
this.updateList();
// Load models and do initial render
this.loadModels().then(() => {
this.updateList();
// Request re-render after models are loaded
this.tui.requestRender();
});
}
private loadModels(): void {
private async loadModels(): Promise<void> {
// Load available models fresh (includes custom models from ~/.pi/agent/models.json)
const { models: availableModels, error } = getAvailableModels();
const { models: availableModels, error } = await getAvailableModels();
// If there's an error loading models.json, we'll show it via the "no models" path
// The error will be displayed to the user

View file

@ -0,0 +1,107 @@
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getOAuthProviders, type OAuthProviderInfo } from "../oauth/index.js";
/**
* Component that renders an OAuth provider selector
*/
export class OAuthSelectorComponent extends Container {
private listContainer: Container;
private allProviders: OAuthProviderInfo[] = [];
private selectedIndex: number = 0;
private mode: "login" | "logout";
private onSelectCallback: (providerId: string) => void;
private onCancelCallback: () => void;
constructor(mode: "login" | "logout", onSelect: (providerId: string) => void, onCancel: () => void) {
super();
this.mode = mode;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Load all OAuth providers
this.loadProviders();
// Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new Spacer(1));
// Add title
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
this.addChild(new Text(chalk.bold(title), 0, 0));
this.addChild(new Spacer(1));
// Create list container
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
// Initial render
this.updateList();
}
private loadProviders(): void {
this.allProviders = getOAuthProviders();
this.allProviders = this.allProviders.filter((p) => p.available);
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.allProviders.length; i++) {
const provider = this.allProviders[i];
if (!provider) continue;
const isSelected = i === this.selectedIndex;
const isAvailable = provider.available;
let line = "";
if (isSelected) {
const prefix = chalk.blue("→ ");
const text = isAvailable ? chalk.blue(provider.name) : chalk.dim(provider.name);
line = prefix + text;
} else {
const text = isAvailable ? ` ${provider.name}` : chalk.dim(` ${provider.name}`);
line = text;
}
this.listContainer.addChild(new Text(line, 0, 0));
}
// Show "no providers" if empty
if (this.allProviders.length === 0) {
const message =
this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
this.listContainer.addChild(new Text(chalk.gray(` ${message}`), 0, 0));
}
}
handleInput(keyData: string): void {
// Up arrow
if (keyData === "\x1b[A") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
}
// Down arrow
else if (keyData === "\x1b[B") {
this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
this.updateList();
}
// Enter
else if (keyData === "\r") {
const selectedProvider = this.allProviders[this.selectedIndex];
if (selectedProvider?.available) {
this.onSelectCallback(selectedProvider.id);
}
}
// Escape
else if (keyData === "\x1b") {
this.onCancelCallback();
}
}
}

View file

@ -4,6 +4,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui";
import {
CombinedAutocompleteProvider,
Container,
Input,
Loader,
Markdown,
ProcessTerminal,
@ -12,9 +13,11 @@ import {
TUI,
} from "@mariozechner/pi-tui";
import chalk from "chalk";
import { exec } from "child_process";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { exportSessionToHtml } from "../export-html.js";
import { getApiKeyForModel } from "../model-config.js";
import { listOAuthProviders, login, logout } from "../oauth/index.js";
import type { SessionManager } from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { AssistantMessageComponent } from "./assistant-message.js";
@ -22,6 +25,7 @@ import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
import { FooterComponent } from "./footer.js";
import { ModelSelectorComponent } from "./model-selector.js";
import { OAuthSelectorComponent } from "./oauth-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
@ -63,6 +67,9 @@ export class TuiRenderer {
// User message selector (for branching)
private userMessageSelector: UserMessageSelectorComponent | null = null;
// OAuth selector
private oauthSelector: any | null = null;
// Track if this is the first user message (to skip spacer)
private isFirstUserMessage = true;
@ -117,9 +124,28 @@ export class TuiRenderer {
description: "Create a new branch from a previous message",
};
const loginCommand: SlashCommand = {
name: "login",
description: "Login with OAuth provider",
};
const logoutCommand: SlashCommand = {
name: "logout",
description: "Logout from OAuth provider",
};
// Setup autocomplete for file paths and slash commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[thinkingCommand, modelCommand, exportCommand, sessionCommand, changelogCommand, branchCommand],
[
thinkingCommand,
modelCommand,
exportCommand,
sessionCommand,
changelogCommand,
branchCommand,
loginCommand,
logoutCommand,
],
process.cwd(),
);
this.editor.setAutocompleteProvider(autocompleteProvider);
@ -185,7 +211,7 @@ export class TuiRenderer {
};
// Handle editor submission
this.editor.onSubmit = (text: string) => {
this.editor.onSubmit = async (text: string) => {
text = text.trim();
if (!text) return;
@ -233,6 +259,20 @@ export class TuiRenderer {
return;
}
// Check for /login command
if (text === "/login") {
this.showOAuthSelector("login");
this.editor.setText("");
return;
}
// Check for /logout command
if (text === "/logout") {
this.showOAuthSelector("logout");
this.editor.setText("");
return;
}
// Normal message submission - validate model and API key first
const currentModel = this.agent.state.model;
if (!currentModel) {
@ -245,7 +285,8 @@ export class TuiRenderer {
return;
}
const apiKey = getApiKeyForModel(currentModel);
// Validate API key (async)
const apiKey = await getApiKeyForModel(currentModel);
if (!apiKey) {
this.showError(
`No API key found for ${currentModel.provider}.\n\n` +
@ -595,6 +636,7 @@ export class TuiRenderer {
private showModelSelector(): void {
// Create model selector with current model
this.modelSelector = new ModelSelectorComponent(
this.ui,
this.agent.state.model,
this.settingsManager,
(model) => {
@ -719,6 +761,121 @@ export class TuiRenderer {
this.ui.setFocus(this.editor);
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
// For logout mode, filter to only show logged-in providers
let providersToShow: string[] = [];
if (mode === "logout") {
const loggedInProviders = listOAuthProviders();
if (loggedInProviders.length === 0) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("No OAuth providers logged in. Use /login first."), 1, 0));
this.ui.requestRender();
return;
}
providersToShow = loggedInProviders;
}
// Create OAuth selector
this.oauthSelector = new OAuthSelectorComponent(
mode,
async (providerId: any) => {
// Hide selector first
this.hideOAuthSelector();
if (mode === "login") {
// Handle login
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim(`Logging in to ${providerId}...`), 1, 0));
this.ui.requestRender();
try {
await login(
providerId,
(url: string) => {
// Show auth URL to user
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.cyan("Opening browser to:"), 1, 0));
this.chatContainer.addChild(new Text(chalk.cyan(url), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.yellow("Paste the authorization code below:"), 1, 0),
);
this.ui.requestRender();
// Open URL in browser
const openCmd =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${openCmd} "${url}"`);
},
async () => {
// Prompt for code with a simple Input
return new Promise<string>((resolve) => {
const codeInput = new Input();
codeInput.onSubmit = () => {
const code = codeInput.getValue();
// Restore editor
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
resolve(code);
};
this.editorContainer.clear();
this.editorContainer.addChild(codeInput);
this.ui.setFocus(codeInput);
this.ui.requestRender();
});
},
);
// Success
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.green(`✓ Successfully logged in to ${providerId}`), 1, 0));
this.chatContainer.addChild(new Text(chalk.dim(`Tokens saved to ~/.pi/agent/oauth.json`), 1, 0));
this.ui.requestRender();
} catch (error: any) {
this.showError(`Login failed: ${error.message}`);
}
} else {
// Handle logout
try {
await logout(providerId);
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.green(`✓ Successfully logged out of ${providerId}`), 1, 0),
);
this.chatContainer.addChild(
new Text(chalk.dim(`Credentials removed from ~/.pi/agent/oauth.json`), 1, 0),
);
this.ui.requestRender();
} catch (error: any) {
this.showError(`Logout failed: ${error.message}`);
}
}
},
() => {
// Cancel - just hide the selector
this.hideOAuthSelector();
this.ui.requestRender();
},
);
// Replace editor with selector
this.editorContainer.clear();
this.editorContainer.addChild(this.oauthSelector);
this.ui.setFocus(this.oauthSelector);
this.ui.requestRender();
}
private hideOAuthSelector(): void {
// Replace selector with editor in the container
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.oauthSelector = null;
this.ui.setFocus(this.editor);
}
private handleExportCommand(text: string): void {
// Parse optional filename from command: /export [filename]
const parts = text.split(/\s+/);