mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 12:03:23 +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
|
|
@ -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