@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
232 lines • 11.1 kB
JavaScript
import { replaceControlCharacters } from "@earendil-works/pi-tui";
import { APP_NAME, getAuthPath } from "../config.js";
import { CredentialSynchronizationError } from "../core/model-runtime.js";
import { findMatchingProviders } from "../core/provider-matching.js";
import { RADIUS_PROVIDER_ID } from "../core/radius.js";
import { planRadiusMcpServer } from "../core/radius-mcp.js";
import { addMcpServerConfig } from "../extensions/mcp/config.js";
import { displayUrl, webUrl } from "../utils/web-url.js";
const AUTH_LOGIN_USAGE = `${APP_NAME} auth login <provider> [--api-key] [--no-browser]`;
/** Run `pi auth login <provider>` in the terminal and return the process exit code. */
export async function runAuthLogin(args, options) {
const { modelRuntime, terminal, signal } = options;
const parsed = parseLoginRequest(args);
if (parsed.type === "invalid") {
terminal.error(parsed.message);
return 1;
}
const { request } = parsed;
const { providerRef } = request;
const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0];
if (!provider) {
const loginProviders = modelRuntime
.getProviders()
.filter((candidate) => candidate.auth.oauth || candidate.auth.apiKey?.login)
.map((candidate) => candidate.id)
.sort();
terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`);
terminal.error(`Providers: ${loginProviders.join(", ")}`);
return 1;
}
if (!terminal.interactive) {
terminal.error(`"${AUTH_LOGIN_USAGE}" requires an interactive terminal`);
return 1;
}
const authType = selectAuthType(provider, request.apiKey);
if (authType.type === "unavailable") {
terminal.error(authType.message);
return 1;
}
try {
await modelRuntime.login(provider.id, authType.type, createLoginInteraction(provider.id, authType.type, request, terminal, signal), { getDeviceId: options.getDeviceId });
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof CredentialSynchronizationError) {
terminal.error(`Logged in to ${provider.name}, but local model state could not be synchronized: ${message}`);
}
else if (signal.aborted || message === "Login cancelled") {
terminal.error("Login cancelled.");
}
else {
terminal.error(`Failed to log in to ${provider.name}: ${message}`);
}
return 1;
}
terminal.write(`${authType.type === "oauth" ? `Logged in to ${provider.name}` : `Saved API key for ${provider.name}`}. Credentials saved to ${getAuthPath()}`);
await refreshProviderCatalog(provider, modelRuntime, terminal, signal);
if (provider.id === RADIUS_PROVIDER_ID)
configureRadiusMcpServer(provider, options);
return 0;
}
function parseLoginRequest(args) {
const usage = { type: "invalid", message: `Usage: ${AUTH_LOGIN_USAGE}` };
const request = { providerRef: "", apiKey: false, browser: true };
for (const arg of args) {
if (arg === "--api-key")
request.apiKey = true;
else if (arg === "--no-browser")
request.browser = false;
else if (arg.startsWith("-") || request.providerRef)
return usage;
else
request.providerRef = arg;
}
if (!request.providerRef)
return usage;
if (request.apiKey && !request.browser) {
return { type: "invalid", message: "--no-browser only applies to account sign-in; omit it with --api-key." };
}
return { type: "valid", request };
}
/**
* Account login unless `apiKey` asks for an API key. Providers without account login use their API key login, since
* that is the only way in.
*/
function selectAuthType(provider, apiKey) {
const hasApiKeyLogin = provider.auth.apiKey?.login !== undefined;
if (!apiKey && provider.auth.oauth)
return { type: "oauth" };
if (hasApiKeyLogin)
return { type: "api_key" };
if (apiKey && provider.auth.oauth) {
return { type: "unavailable", message: `${provider.name} has no API key login. Omit --api-key to sign in.` };
}
const method = provider.auth.apiKey?.name ?? "ambient credentials";
return { type: "unavailable", message: `${provider.name} is configured outside ${APP_NAME} (${method}).` };
}
/** Adapt provider prompts and events to the command's selected sign-in method. */
function createLoginInteraction(providerId, authType, request, terminal, signal) {
// Account logins list the browser sign-in first, as /login; API key setup asks every selection.
const methodChoice = authType === "api_key" ? "ask" : request.browser ? "browser" : "other";
let skippedMethods = [];
async function selectMethod(prompt, promptSignal) {
const [first, ...others] = prompt.options;
if (methodChoice === "ask" || !first) {
return selectInTerminal(prompt.message, prompt.options, terminal, promptSignal);
}
if (methodChoice === "browser") {
skippedMethods = others.map((option) => option.label);
return first.id;
}
const [onlyOther, ...moreOthers] = others;
// A provider whose only method is the browser sign-in still gets it, without opening the browser.
if (!onlyOther)
return first.id;
if (moreOthers.length > 0)
return selectInTerminal(prompt.message, others, terminal, promptSignal);
terminal.write(`${prompt.message} ${onlyOther.label}`);
return onlyOther.id;
}
return {
signal,
prompt(prompt) {
// The browser callback can finish a step before the pasted code, cancelling just that prompt.
const promptSignal = prompt.signal ? AbortSignal.any([signal, prompt.signal]) : signal;
if (prompt.type === "select")
return selectMethod(prompt, promptSignal);
const placeholder = prompt.placeholder ? ` (${prompt.placeholder})` : "";
const message = `${prompt.message}${placeholder}: `;
return prompt.type === "secret"
? terminal.secret(message, promptSignal)
: terminal.question(message, promptSignal);
},
notify(event) {
if (event.type === "auth_url") {
terminal.write(`Open this URL to sign in:\n ${displayUrl(event.url)}`);
if (event.instructions)
terminal.write(event.instructions);
const target = webUrl(event.url);
if (target !== undefined && request.browser)
terminal.openUrl(target);
// Providers without a method choice have nothing else to offer, so they get no hint.
const rerun = `"${APP_NAME} auth login ${providerId} --no-browser"`;
const [onlyMethod, ...moreMethods] = skippedMethods;
if (onlyMethod !== undefined && moreMethods.length === 0) {
terminal.write(`To use "${onlyMethod}" instead, run ${rerun}`);
}
else if (onlyMethod !== undefined) {
const labels = skippedMethods.map((label) => `"${label}"`).join(", ");
terminal.write(`Other sign-in methods: ${labels}. Run ${rerun} to choose one.`);
}
}
else if (event.type === "device_code") {
terminal.write(`Open ${displayUrl(event.verificationUri)} and enter code: ${event.userCode}`);
const target = webUrl(event.verificationUri);
if (target !== undefined && request.browser && event.openBrowserOnConfirm !== false) {
terminal.openUrl(target);
}
terminal.write("Waiting for authentication...");
}
else if (event.type === "info") {
terminal.write(event.message);
for (const link of event.links ?? []) {
terminal.write(` ${link.label ? `${link.label}: ` : ""}${displayUrl(link.url)}`);
}
}
else {
terminal.write(event.message);
}
},
};
}
async function selectInTerminal(message, options, terminal, signal) {
terminal.write(message);
options.forEach((option, index) => {
terminal.write(` ${index + 1}. ${option.label}${option.description ? ` - ${option.description}` : ""}`);
});
while (true) {
const answer = (await terminal.question(`Select 1-${options.length}: `, signal)).trim();
const option = /^\d+$/u.test(answer) ? options[Number(answer) - 1] : undefined;
if (option)
return option.id;
terminal.write(`Enter a number from 1 to ${options.length}.`);
}
}
/** Fetch the provider's model catalog now, since dynamic catalogs stay empty until the first authenticated refresh. */
async function refreshProviderCatalog(provider, modelRuntime, terminal, signal) {
// The login is saved by now, so Ctrl+C only skips the refresh.
const cancelled = () => {
terminal.write(`Skipped the ${provider.name} model catalog refresh; ${APP_NAME} refreshes it on next start.`);
};
try {
const result = await modelRuntime.refresh({
providers: [provider.id],
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
});
if (signal.aborted) {
cancelled();
}
else if (result.aborted) {
terminal.write(`The ${provider.name} model catalog refresh timed out; ${APP_NAME} refreshes it on next start.`);
}
else if (result.errors.size > 0) {
terminal.write(`The ${provider.name} model catalog could not be refreshed; ${APP_NAME} retries on next start.`);
}
}
catch (error) {
if (signal.aborted) {
cancelled();
return;
}
terminal.write(`The ${provider.name} model catalog could not be refreshed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Point the Radius MCP server in the global mcp.json at the Radius login without asking, since the login is what the
* server needs; /login asks instead. Nothing changes when a global server already uses this login.
*/
function configureRadiusMcpServer(provider, options) {
const { terminal } = options;
const plan = planRadiusMcpServer(provider.id, options.agentDir, options.cwd);
if (!plan)
return;
try {
const replaced = addMcpServerConfig(plan.mcpPath, plan.name, plan.config);
terminal.write(`${replaced ? "Updated" : "Added"} MCP server "${plan.name}" in ${plan.mcpPath} to use the ${provider.name} login.`);
}
catch (error) {
terminal.error(`Could not update ${plan.mcpPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
//# sourceMappingURL=auth-login.js.map