mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 20:00:41 +00:00
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:
parent
387cc97bac
commit
587d7c39a4
17 changed files with 1632 additions and 76 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
128
packages/coding-agent/src/oauth/anthropic.ts
Normal file
128
packages/coding-agent/src/oauth/anthropic.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
115
packages/coding-agent/src/oauth/index.ts
Normal file
115
packages/coding-agent/src/oauth/index.ts
Normal 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;
|
||||
}
|
||||
95
packages/coding-agent/src/oauth/storage.ts
Normal file
95
packages/coding-agent/src/oauth/storage.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
107
packages/coding-agent/src/tui/oauth-selector.ts
Normal file
107
packages/coding-agent/src/tui/oauth-selector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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+/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue