feat(coding-agent): add pi auth login and logout

pi auth login <provider> logs in from the shell without starting a
session, like pi mcp login. Account sign-in is the default and takes
the provider's browser method; when the provider has another method,
it prints a hint to rerun with --no-browser. --no-browser skips the
browser method and uses the other one, such as device code, asking
only when there are several. --api-key saves an API key instead and
cannot be combined with --no-browser. After logging in, it fetches the
provider's model catalog, and for Radius it points the Radius MCP
server in the global mcp.json at the login without asking.

pi auth logout <provider> removes the stored credentials like /logout
and succeeds when nothing is stored. For MCP servers that use provider
credentials, pi mcp login and logout now refer to these commands
instead of /login and /logout.

/login shares the check for the Radius MCP server entry and the web
URL helpers with the new command.
This commit is contained in:
Vegard Stikbakke 2026-11-04 18:20:08 +01:00
parent e5f3b41506
commit 9b9cb30ae1
21 changed files with 1392 additions and 68 deletions

View file

@ -11,6 +11,7 @@
- `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711))
- Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545))
- `pi.registerMarkdownTransformer()` takes an optional `{ messageTypes }` that selects the Markdown surfaces a transformer runs on. Besides user, assistant, and thinking Markdown, transformers can now opt into `custom` (custom messages and skill blocks), `summary` (branch and compaction summaries), and `changelog` (What's New, `/changelog`, the Extension Updates notice, and the Update Available note). Without options a transformer runs on user, assistant, and thinking Markdown as before. The built-in Mermaid renderer now also draws diagrams in expanded branch and compaction summaries
- `pi auth login <provider>` logs in to a provider from the shell without starting a session, for example `pi auth login radius`. It signs in with an account in the browser and exits; `--api-key` saves an API key instead, and `--no-browser` uses device code or copy-code sign-in instead. Logging in to Radius also configures the Radius MCP server. `pi auth logout <provider>` removes the stored credentials, like `/logout`
### Changed

View file

@ -12,6 +12,8 @@ pi uninstall <source> [options]
pi update [target] [options]
pi list
pi config [options]
pi auth login <provider> [--api-key] [--no-browser]
pi auth logout <provider>
pi auth <check|print-api-key|print-bearer-token> [options]
pi mcp <add|remove|list|login|logout> [options]
```
@ -302,11 +304,22 @@ export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
## Credential commands
`pi auth login <provider>` logs in to a provider in the terminal and exits, without starting a session. It signs in with an account and opens the browser, like `/login <provider>`. Providers without account sign-in ask for an API key. Logging in to Radius also adds the Radius MCP server to `~/.pi/agent/mcp.json` when no global server uses that login yet. It requires an interactive terminal.
| Option | Description |
|---|---|
| `--api-key` | Save an API key instead of signing in with an account; the key is read without echoing it |
| `--no-browser` | Do not open a browser; use the provider's other sign-in method, such as a device code, for example over SSH. Asks when there are several. Not allowed with `--api-key` |
`pi auth logout <provider>` removes the credentials that `pi auth login` or `/login` stored, like `/logout`. Environment variables and `models.json` config are unchanged. It exits with status `0` when nothing is stored, so it is safe to repeat.
```sh
pi auth login radius
pi auth logout radius
pi auth check --provider openai --json
```
Authentication commands require `--provider <provider>` or `--model <model>`. See [Providers](providers.md) for supported methods.
The other authentication commands require `--provider <provider>` or `--model <model>`. See [Providers](providers.md) for supported methods.
| Command | Description |
|---|---|

View file

@ -293,7 +293,7 @@ ${chalk.bold("Commands:")}
${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs
${APP_NAME} list List installed extensions from settings
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
${APP_NAME} auth <command> Print credentials or check provider readiness
${APP_NAME} auth <command> Log in or out, print credentials, or check provider readiness
${APP_NAME} mcp <command> Manage MCP servers
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth/mcp

View file

@ -38,11 +38,35 @@ export function isAuthCommandHelp(args: string[]): boolean {
export function printAuthCommandHelp(): void {
console.log(`Usage:
pi auth login <provider> [--api-key] [--no-browser]
pi auth logout <provider>
pi auth print-api-key [--provider <provider>] [--model <model>]
pi auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]
pi auth check [--provider <provider>] [--model <model>] [--json] [--credentials] [--no-refresh]
Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`);
Log in to and out of model providers, print their credentials for external clients, or check that they are ready.
Credentials are stored in ~/.pi/agent/auth.json.
Commands:
login <provider> Sign in with an account in the browser, or save an API key, then exit
logout <provider> Remove the stored credentials; environment variables and models.json stay
print-api-key Print the resolved API key
print-bearer-token Print a resolved OAuth bearer token (refreshes if expired)
check Print ready, not_ready, or invalid; exit with 0, 1, or 2
Options for print-api-key, print-bearer-token, and check (at least one is required):
--provider <provider> Resolve credentials for a provider
--model <model> Resolve credentials from a model
Options for login:
--api-key Save an API key instead of signing in with an account
--no-browser Do not open a browser; use another sign-in method, e.g. device code
Other options:
--min-expiry <duration> Minimum remaining token lifetime for print-bearer-token, e.g. 30m or 1h
--json Print the check result as JSON
--credentials Emit the credential from check, or include it in JSON output
--no-refresh Do not refresh expired OAuth credentials during check`);
}
export function parseAuthCommand(args: string[]): AuthCommand | undefined {
@ -58,7 +82,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined {
: undefined;
if (!kind) {
throw new AuthCommandError(
`Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`,
`Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth login", "${APP_NAME} auth logout", "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`,
);
}

View file

@ -0,0 +1,277 @@
import type { AuthInteraction, AuthPrompt, AuthType, Provider } from "@earendil-works/pi-ai";
import { replaceControlCharacters } from "@earendil-works/pi-tui";
import { APP_NAME, getAuthPath } from "../config.ts";
import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts";
import { findMatchingProviders } from "../core/provider-matching.ts";
import { RADIUS_PROVIDER_ID } from "../core/radius.ts";
import { planRadiusMcpServer } from "../core/radius-mcp.ts";
import { addMcpServerConfig } from "../extensions/mcp/config.ts";
import { displayUrl, webUrl } from "../utils/web-url.ts";
import type { LoginTerminal } from "./auth-terminal.ts";
/** Dependencies of `runAuthLogin`, injected so tests can script the terminal. */
export interface AuthLoginOptions {
modelRuntime: ModelRuntime;
terminal: LoginTerminal;
agentDir: string;
cwd: string;
getDeviceId: () => string;
/** Aborts the login, e.g. on Ctrl+C. */
signal: AbortSignal;
}
const AUTH_LOGIN_USAGE = `${APP_NAME} auth login <provider> [--api-key] [--no-browser]`;
/** Parsed `pi auth login` arguments. */
interface LoginRequest {
providerRef: string;
/** Log in with an API key instead of an account. */
apiKey: boolean;
/** Open the browser and take the browser sign-in method without asking. */
browser: boolean;
}
/** Run `pi auth login <provider>` in the terminal and return the process exit code. */
export async function runAuthLogin(args: string[], options: AuthLoginOptions): Promise<number> {
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: unknown) {
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: string[],
): { type: "valid"; request: LoginRequest } | { type: "invalid"; message: string } {
const usage = { type: "invalid", message: `Usage: ${AUTH_LOGIN_USAGE}` } as const;
const request: LoginRequest = { 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: Provider,
apiKey: boolean,
): { type: AuthType } | { type: "unavailable"; message: string } {
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: string,
authType: AuthType,
request: LoginRequest,
terminal: LoginTerminal,
signal: AbortSignal,
): AuthInteraction {
// 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: string[] = [];
async function selectMethod(prompt: Extract<AuthPrompt, { type: "select" }>, promptSignal: AbortSignal) {
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: string,
options: readonly { id: string; label: string; description?: string }[],
terminal: LoginTerminal,
signal: AbortSignal,
): Promise<string> {
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: Provider,
modelRuntime: ModelRuntime,
terminal: LoginTerminal,
signal: AbortSignal,
) {
// 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: unknown) {
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: Provider, options: AuthLoginOptions): void {
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: unknown) {
terminal.error(`Could not update ${plan.mcpPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

View file

@ -0,0 +1,72 @@
import type { CredentialInfo } from "@earendil-works/pi-ai";
import { replaceControlCharacters } from "@earendil-works/pi-tui";
import { APP_NAME } from "../config.ts";
import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts";
import { findMatchingProviders } from "../core/provider-matching.ts";
import type { AuthOutput } from "./auth-terminal.ts";
/** Dependencies of `runAuthLogout`, injected so tests can capture the output. */
export interface AuthLogoutOptions {
modelRuntime: ModelRuntime;
terminal: AuthOutput;
signal: AbortSignal;
}
const AUTH_LOGOUT_USAGE = `${APP_NAME} auth logout <provider>`;
/**
* Run `pi auth logout <provider>`: remove the credential `pi auth login` or /login stored, and return the exit code.
* Environment variables and models.json config stay, as with /logout.
*/
export async function runAuthLogout(args: string[], options: AuthLogoutOptions): Promise<number> {
const { modelRuntime, terminal, signal } = options;
const [providerRef, ...rest] = args;
if (providerRef === undefined || providerRef.startsWith("-") || rest.length > 0) {
terminal.error(`Usage: ${AUTH_LOGOUT_USAGE}`);
return 1;
}
let stored: readonly CredentialInfo[];
try {
stored = await modelRuntime.listCredentials({ signal });
} catch (error: unknown) {
terminal.error(`Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`);
return 1;
}
const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0];
// Providers registered by extensions are not loaded here, so their stored credentials match by id alone.
const credential = stored.find((candidate) => candidate.providerId === (provider?.id ?? providerRef));
if (!provider && !credential) {
terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`);
const storedIds = stored.map((credential) => credential.providerId).sort();
terminal.error(`Stored credentials: ${storedIds.join(", ") || "none"}`);
return 1;
}
const providerName = provider?.name ?? providerRef;
if (!credential) {
terminal.write(
`No stored credentials for ${providerName}. Environment variables and models.json config are unchanged.`,
);
return 0;
}
try {
await modelRuntime.logout(credential.providerId, { signal });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof CredentialSynchronizationError) {
terminal.error(
`Credentials removed for ${providerName}, but local model state could not be synchronized: ${message}`,
);
} else {
terminal.error(`Logout failed: ${message}`);
}
return 1;
}
terminal.write(
credential.type === "oauth"
? `Logged out of ${providerName}.`
: `Removed stored API key for ${providerName}. Environment variables and models.json config are unchanged.`,
);
return 0;
}

View file

@ -0,0 +1,78 @@
import { openBrowser } from "../utils/open-browser.ts";
import { questionTerminal } from "../utils/terminal-question.ts";
/** Output shared by the terminal authentication commands. */
export interface AuthOutput {
write(line: string): void;
error(line: string): void;
}
/** Line-based terminal I/O for `pi auth login`. Prompts reject when `signal` aborts. */
export interface LoginTerminal extends AuthOutput {
/** Whether stdin and stdout are a terminal; login prompts need one. */
interactive: boolean;
question(message: string, signal: AbortSignal): Promise<string>;
/** Read a line without echoing it. */
secret(message: string, signal: AbortSignal): Promise<string>;
openUrl(url: string): void;
}
/** Terminal I/O on stdin and stdout. `onInterrupt` runs on Ctrl+C or EOF in a question, or Ctrl+C in a secret. */
export function createProcessLoginTerminal(onInterrupt: () => void): LoginTerminal {
return {
interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
write: (line) => process.stdout.write(`${line}\n`),
error: (line) => process.stderr.write(`${line}\n`),
question: (message, signal) => questionTerminal(message, { signal, output: process.stdout, onInterrupt }),
secret: (message, signal) => readSecret(message, signal, onInterrupt),
openUrl: openBrowser,
};
}
function readSecret(message: string, signal: AbortSignal, onInterrupt: () => void): Promise<string> {
const stdin = process.stdin;
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("Login cancelled"));
return;
}
let value = "";
const finish = () => {
stdin.off("data", onData);
signal.removeEventListener("abort", onAbort);
stdin.setRawMode(false);
stdin.pause();
process.stdout.write("\n");
};
const onAbort = () => {
finish();
reject(new Error("Login cancelled"));
};
const onData = (data: Buffer) => {
const chunk = data.toString("utf8");
// Ignore escape sequences such as arrow keys.
if (chunk.startsWith("\u001b")) return;
for (const char of chunk) {
if (char === "\r" || char === "\n") {
finish();
resolve(value);
return;
}
if (char === "\u0003") {
onInterrupt();
return;
}
if (char === "\u007f" || char === "\b") {
value = Array.from(value).slice(0, -1).join("");
} else if (char >= " ") {
value += char;
}
}
};
process.stdout.write(message);
stdin.setRawMode(true);
stdin.resume();
stdin.on("data", onData);
signal.addEventListener("abort", onAbort, { once: true });
});
}

View file

@ -0,0 +1,11 @@
/** Match provider IDs and display names exactly, ignoring case and surrounding whitespace in the reference. */
export function findMatchingProviders<T extends { id: string; name: string }>(
providers: readonly T[],
providerRef: string,
): T[] {
const normalized = providerRef.trim().toLowerCase();
if (!normalized) return [];
return providers.filter(
(provider) => provider.id.toLowerCase() === normalized || provider.name.toLowerCase() === normalized,
);
}

View file

@ -0,0 +1,44 @@
import path from "node:path";
import { mcpNamespace } from "@earendil-works/pi-mcp";
import { loadMcpConfig } from "../extensions/mcp/config.ts";
import type { McpHttpServerConfig } from "./mcp-servers.ts";
import { RADIUS_MCP_URL } from "./radius.ts";
/** Change to the global mcp.json that points the Radius MCP server at a Radius login. */
export interface RadiusMcpServerPlan {
mcpPath: string;
name: string;
config: McpHttpServerConfig;
}
/**
* The global mcp.json entry that makes the Radius MCP server use the login of `providerId`, adding the server when
* missing, or undefined when a global server already uses that login.
*/
export function planRadiusMcpServer(
providerId: string,
agentDir: string,
cwd: string,
): RadiusMcpServerPlan | undefined {
const normalizeUrl = (url: string) => url.replace(/\/+$/u, "");
const { servers } = loadMcpConfig({ agentDir, cwd, projectTrusted: false });
const existing = servers.find(
(server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL),
);
if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return undefined;
let name = existing?.name ?? "radius";
if (!existing) {
const namespaces = new Set(servers.map((server) => mcpNamespace(server.name)));
for (let suffix = 1; namespaces.has(mcpNamespace(name)); suffix++) {
name = suffix === 1 ? "radius-mcp" : `radius-mcp-${suffix}`;
}
}
const config: McpHttpServerConfig =
existing && "url" in existing.config
? { ...existing.config, auth: { provider: providerId } }
: { url: RADIUS_MCP_URL, auth: { provider: providerId } };
// `auth` replaces the MCP OAuth sign-in.
delete config.oauth;
return { mcpPath: path.join(agentDir, "mcp.json"), name, config };
}

View file

@ -7,13 +7,13 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline/promises";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME } from "../../config.ts";
import { validateMcpServerConfig } from "../../core/mcp-servers.ts";
import { ModelRuntime } from "../../core/model-runtime.ts";
import { ProjectTrustStore } from "../../core/trust-manager.ts";
import { openBrowser } from "../../utils/open-browser.ts";
import { questionTerminal } from "../../utils/terminal-question.ts";
import {
addMcpServerConfig,
getMcpToolExposure,
@ -285,7 +285,7 @@ export async function runMcpCommand(args: string[], options: McpCommandOptions):
const authProvider = getAuthProvider(entry);
if (authProvider) {
error(
`MCP server "${name}" uses the credentials of provider "${authProvider}". Run /${command} ${authProvider} in ${APP_NAME}.`,
`MCP server "${name}" uses the credentials of provider "${authProvider}". Run "${APP_NAME} auth ${command} ${authProvider}".`,
);
return 1;
}
@ -659,21 +659,19 @@ async function login(
/**
* The pasted redirect URL in a terminal; otherwise only the browser callback can finish the sign-in.
* Resolves to undefined when `signal` aborts: the callback arrived, or the sign-in timed out.
* Resolves to undefined on cancellation or EOF, or when the callback arrives or the sign-in times out.
*/
async function waitForRedirectUrl(signal: AbortSignal, interactive: boolean): Promise<string | undefined> {
if (!interactive) {
if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
return undefined;
}
const readline = createInterface({ input: process.stdin, output: process.stderr });
try {
return await readline.question("If the browser cannot reach this machine, paste the URL it was redirected to: ", {
return await questionTerminal("If the browser cannot reach this machine, paste the URL it was redirected to: ", {
signal,
output: process.stderr,
});
} catch {
return undefined;
} finally {
readline.close();
}
}

View file

@ -26,6 +26,9 @@ import {
printAuthCommandHelp,
validateAuthCommandArgs,
} from "./cli/auth-command.ts";
import { runAuthLogin } from "./cli/auth-login.ts";
import { runAuthLogout } from "./cli/auth-logout.ts";
import { createProcessLoginTerminal } from "./cli/auth-terminal.ts";
import { resolveCredentialForPrint } from "./cli/credential-print.ts";
import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
@ -130,11 +133,55 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean {
return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);
}
async function runAuthLoginCommand(args: string[]): Promise<number> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal: AbortSignal.timeout(15_000) });
const controller = new AbortController();
const interrupt = () => controller.abort();
process.on("SIGINT", interrupt);
try {
return await runAuthLogin(args, {
modelRuntime,
terminal: createProcessLoginTerminal(interrupt),
agentDir,
cwd,
getDeviceId: () => settingsManager.getOrCreateDeviceId(),
signal: controller.signal,
});
} finally {
process.off("SIGINT", interrupt);
}
}
async function runAuthLogoutCommand(args: string[]): Promise<number> {
const signal = AbortSignal.timeout(15_000);
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal });
return runAuthLogout(args, {
modelRuntime,
terminal: { write: (line) => console.log(line), error: (line) => console.error(line) },
signal,
});
}
async function runAuthCommand(args: string[]): Promise<boolean> {
if (isAuthCommandHelp(args)) {
printAuthCommandHelp();
return true;
}
if (args[0] === "auth" && args[1] === "login") {
// main() already applied --offline.
process.exitCode = await runAuthLoginCommand(args.slice(2).filter((arg) => arg !== "--offline"));
return true;
}
if (args[0] === "auth" && args[1] === "logout") {
process.exitCode = await runAuthLogoutCommand(args.slice(2).filter((arg) => arg !== "--offline"));
return true;
}
let command: AuthCommand | undefined;
try {

View file

@ -13,30 +13,12 @@ import {
} from "@earendil-works/pi-tui";
import { copyToClipboard } from "../../../utils/clipboard.ts";
import { openBrowser } from "../../../utils/open-browser.ts";
import { displayUrl, webUrl } from "../../../utils/web-url.ts";
import { theme } from "../theme/theme.ts";
import { AuthUrlComponent } from "./auth-url.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/**
* The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom
* protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a
* terminal hyperlink early.
*/
function webUrl(value: string): string | undefined {
if (!URL.canParse(value)) return undefined;
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
}
/**
* `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a
* hyperlink early or send its own escape sequences to the terminal.
*/
function displayUrl(value: string): string {
return webUrl(value) ?? replaceControlCharacters(value);
}
/** `text` as a terminal hyperlink to `url` when that is a web URL, else plain `text`. */
function webLink(text: string, url: string): string {
const target = webUrl(url);

View file

@ -96,7 +96,6 @@ import type {
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import type { McpHttpServerConfig } from "../../core/mcp-servers.ts";
import { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts";
import {
defaultModelPerProvider,
@ -105,7 +104,9 @@ import {
} from "../../core/model-resolver.ts";
import { CredentialSynchronizationError } from "../../core/model-runtime.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts";
import { findMatchingProviders } from "../../core/provider-matching.ts";
import { RADIUS_PROVIDER_ID } from "../../core/radius.ts";
import { planRadiusMcpServer } from "../../core/radius-mcp.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import {
@ -122,7 +123,7 @@ import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
import { addMcpServerConfig, loadMcpConfig } from "../../extensions/mcp/config.ts";
import { addMcpServerConfig } from "../../extensions/mcp/config.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@ -5980,26 +5981,13 @@ export class InteractiveMode {
.sort((a, b) => a.name.localeCompare(b.name));
}
private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] {
const normalizedProviderRef = providerRef.trim().toLowerCase();
if (!normalizedProviderRef) {
return [];
}
return this.getLoginProviderOptions().filter(
(provider) =>
provider.id.toLowerCase() === normalizedProviderRef ||
provider.name.toLowerCase() === normalizedProviderRef,
);
}
private async handleLoginCommand(providerRef?: string): Promise<void> {
if (!providerRef) {
this.showLoginAuthTypeSelector();
return;
}
const providerOptions = this.findLoginProviderOptions(providerRef);
const providerOptions = findMatchingProviders(this.getLoginProviderOptions(), providerRef);
if (providerOptions.length === 1) {
await this.startProviderLogin(providerOptions[0]!);
return;
@ -6520,26 +6508,9 @@ export class InteractiveMode {
* when missing. Nothing is asked when a global server already uses this login.
*/
private offerRadiusMcpServer(providerId: string, providerName: string): void {
const mcpPath = path.join(getAgentDir(), "mcp.json");
const normalizeUrl = (url: string) => url.replace(/\/+$/u, "");
const { servers } = loadMcpConfig({
agentDir: getAgentDir(),
cwd: this.sessionManager.getCwd(),
projectTrusted: false,
});
const existing = servers.find(
(server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL),
);
if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return;
let name = existing?.name ?? "radius";
if (!existing && servers.some((server) => server.name === name)) name = "radius-mcp";
const config: McpHttpServerConfig =
existing && "url" in existing.config
? { ...existing.config, auth: { provider: providerId } }
: { url: RADIUS_MCP_URL, auth: { provider: providerId } };
// `auth` replaces the MCP OAuth sign-in.
delete config.oauth;
const plan = planRadiusMcpServer(providerId, getAgentDir(), this.sessionManager.getCwd());
if (!plan) return;
const { mcpPath, name, config } = plan;
this.showSelector((done) => {
const selector = new ExtensionSelectorComponent(

View file

@ -0,0 +1,29 @@
import { createInterface } from "node:readline/promises";
/** Read one line from stdin, rejecting on abort, Ctrl+C, or EOF and always closing readline. */
export async function questionTerminal(
message: string,
options: {
output: NodeJS.WritableStream;
signal: AbortSignal;
/** Called on Ctrl+C or EOF, not when the caller aborts just this prompt. */
onInterrupt?: () => void;
},
): Promise<string> {
const readline = createInterface({ input: process.stdin, output: options.output });
const interrupted = new AbortController();
// EOF closes readline without settling its pending question; Ctrl+C normally only pauses it.
const onInterrupt = () => interrupted.abort();
readline.on("SIGINT", onInterrupt);
readline.on("close", onInterrupt);
try {
return await readline.question(message, { signal: AbortSignal.any([options.signal, interrupted.signal]) });
} catch (error) {
if (interrupted.signal.aborted) options.onInterrupt?.();
throw error;
} finally {
readline.off("close", onInterrupt);
readline.off("SIGINT", onInterrupt);
readline.close();
}
}

View file

@ -0,0 +1,20 @@
import { replaceControlCharacters } from "@earendil-works/pi-tui";
/**
* The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom
* protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a
* terminal hyperlink early.
*/
export function webUrl(value: string): string | undefined {
if (!URL.canParse(value)) return undefined;
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
}
/**
* `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a
* hyperlink early or send its own escape sequences to the terminal.
*/
export function displayUrl(value: string): string {
return webUrl(value) ?? replaceControlCharacters(value);
}

View file

@ -0,0 +1,481 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InMemoryModelsStore, type Model, type Provider } from "@earendil-works/pi-ai";
import { describe, expect, test, vi } from "vitest";
import { runAuthLogin } from "../src/cli/auth-login.ts";
import type { LoginTerminal } from "../src/cli/auth-terminal.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts";
const PROVIDER_ID = "login-test";
interface ScriptedTerminal extends LoginTerminal {
lines: string[];
errors: string[];
questions: string[];
opened: string[];
}
/** Answers prompts in order; `secret` and `question` share one answer queue. */
function scriptedTerminal(answers: string[], interactive = true): ScriptedTerminal {
const lines: string[] = [];
const errors: string[] = [];
const questions: string[] = [];
const opened: string[] = [];
const answer = async (message: string, signal: AbortSignal) => {
questions.push(message);
if (signal.aborted) throw new Error("Login cancelled");
const next = answers.shift();
if (next === undefined) throw new Error(`Unexpected prompt: ${message}`);
return next;
};
return {
interactive,
lines,
errors,
questions,
opened,
write: (line) => lines.push(line),
error: (line) => errors.push(line),
question: answer,
secret: answer,
openUrl: (url) => opened.push(url),
};
}
function testModel(providerId: string): Model<"openai-completions"> {
return {
id: "test-model",
name: "test-model",
api: "openai-completions",
provider: providerId,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
function testProvider(auth: Provider["auth"], providerId: string): Provider {
const unused = () => {
throw new Error("unused");
};
return {
id: providerId,
name: "Login Test",
auth,
getModels: () => [testModel(providerId)],
stream: unused,
streamSimple: unused,
};
}
const bothMethods: Provider["auth"] = {
oauth: {
name: "Login Test account",
login: async (interaction) => {
const method = await interaction.prompt({
type: "select",
message: "Sign in to Login Test:",
options: [
{ id: "browser", label: "Sign in with browser" },
{ id: "device", label: "Sign in with device code" },
],
});
if (method === "device") {
interaction.notify({
type: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://example.test/device",
});
return { type: "oauth", access: "access-device", refresh: "refresh", expires: Date.now() + 3_600_000 };
}
interaction.notify({
type: "auth_url",
url: "https://example.test/authorize",
instructions: "Finish in the browser",
});
const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" });
return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 };
},
refresh: async (credential) => credential,
toAuth: async (credential) => ({ apiKey: credential.access }),
},
apiKey: {
name: "Login Test API key",
login: async (interaction) => ({
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter Login Test API key" }),
}),
resolve: async () => undefined,
},
};
async function setup(auth: Provider["auth"], providerId = PROVIDER_ID) {
const credentials = AuthStorage.inMemory();
const modelRuntime = await ModelRuntime.create({
credentials,
modelsPath: null,
modelsStore: new InMemoryModelsStore(),
allowModelNetwork: false,
});
modelRuntime.registerNativeProvider(testProvider(auth, providerId));
return { credentials, modelRuntime };
}
function run(
args: string[],
modelRuntime: ModelRuntime,
terminal: LoginTerminal,
signal = new AbortController().signal,
agentDir = "/nonexistent-agent-dir",
) {
return runAuthLogin(args, {
modelRuntime,
terminal,
agentDir,
cwd: "/nonexistent-cwd",
getDeviceId: () => "device",
signal,
});
}
describe("pi auth login", () => {
const usage = "Usage: pi auth login <provider> [--api-key] [--no-browser]";
test("requires exactly one provider and known options", async () => {
const { modelRuntime } = await setup(bothMethods);
for (const args of [[], ["--api-key"], ["--provider", PROVIDER_ID], [PROVIDER_ID, "extra"]]) {
const terminal = scriptedTerminal([]);
expect(await run(args, modelRuntime, terminal)).toBe(1);
expect(terminal.errors).toEqual([usage]);
}
});
test("rejects --no-browser with --api-key", async () => {
const { modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID, "--api-key", "--no-browser"], modelRuntime, terminal)).toBe(1);
expect(terminal.errors).toEqual(["--no-browser only applies to account sign-in; omit it with --api-key."]);
});
test("rejects an unknown provider and lists the providers with a login flow", async () => {
const { modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal([]);
expect(await run(["nope"], modelRuntime, terminal)).toBe(1);
expect(terminal.errors[0]).toBe('Unknown provider "nope".');
expect(terminal.errors[1]).toContain(PROVIDER_ID);
});
test("requires an interactive terminal after checking the arguments", async () => {
const { modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal([], false);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1);
expect(terminal.errors).toEqual([
'"pi auth login <provider> [--api-key] [--no-browser]" requires an interactive terminal',
]);
});
test("signs in with an account in the browser without asking for a method", async () => {
const { credentials, modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal(["the-code"]);
// Matches the provider name as well as the id.
expect(await run(["login test"], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual(["Paste the code: "]);
expect(terminal.lines).toContain("Open this URL to sign in:\n https://example.test/authorize");
expect(terminal.lines).toContain("Finish in the browser");
expect(terminal.opened).toEqual(["https://example.test/authorize"]);
expect(terminal.lines).toContain(
'To use "Sign in with device code" instead, run "pi auth login login-test --no-browser"',
);
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" });
expect(terminal.lines.some((line) => line.startsWith("Logged in to Login Test."))).toBe(true);
});
test("lists several other sign-in methods", async () => {
const { modelRuntime } = await setup({
oauth: {
...bothMethods.oauth!,
login: async (interaction) => {
await interaction.prompt({
type: "select",
message: "Sign in to Login Test:",
options: [
{ id: "browser", label: "Browser" },
{ id: "device", label: "Device code" },
{ id: "copy", label: "Copy code" },
],
});
interaction.notify({ type: "auth_url", url: "https://example.test/authorize" });
return { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 3_600_000 };
},
},
});
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0);
expect(terminal.lines).toContain(
'Other sign-in methods: "Device code", "Copy code". Run "pi auth login login-test --no-browser" to choose one.',
);
});
test("offers no other sign-in methods when the provider has no method choice", async () => {
const { credentials, modelRuntime } = await setup({
oauth: {
...bothMethods.oauth!,
login: async (interaction) => {
interaction.notify({ type: "auth_url", url: "https://example.test/authorize" });
const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" });
return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 };
},
},
});
const terminal = scriptedTerminal(["the-code"]);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0);
expect(terminal.opened).toEqual(["https://example.test/authorize"]);
expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false);
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" });
});
test("--no-browser takes the only other sign-in method and opens nothing", async () => {
const { credentials, modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual([]);
expect(terminal.lines[0]).toBe("Sign in to Login Test: Sign in with device code");
expect(terminal.lines).toContain("Open https://example.test/device and enter code: ABCD-1234");
expect(terminal.opened).toEqual([]);
expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false);
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-device" });
});
test("--no-browser asks among several other sign-in methods and re-asks on an invalid selection", async () => {
const { credentials, modelRuntime } = await setup({
oauth: {
...bothMethods.oauth!,
login: async (interaction) => {
const method = await interaction.prompt({
type: "select",
message: "Sign in to Login Test:",
options: [
{ id: "browser", label: "Browser" },
{ id: "device", label: "Device code" },
{ id: "copy", label: "Copy code" },
],
});
return {
type: "oauth",
access: `access-${method}`,
refresh: "refresh",
expires: Date.now() + 3_600_000,
};
},
},
});
const terminal = scriptedTerminal(["7", "2"]);
expect(await run(["--no-browser", PROVIDER_ID], modelRuntime, terminal)).toBe(0);
expect(terminal.lines.slice(0, 3)).toEqual(["Sign in to Login Test:", " 1. Device code", " 2. Copy code"]);
expect(terminal.lines).toContain("Enter a number from 1 to 2.");
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-copy" });
});
test("--no-browser keeps a provider's only sign-in method without opening the browser", async () => {
const { credentials, modelRuntime } = await setup({
oauth: {
...bothMethods.oauth!,
login: async (interaction) => {
const method = await interaction.prompt({
type: "select",
message: "Sign in to Login Test:",
options: [{ id: "browser", label: "Browser" }],
});
interaction.notify({ type: "auth_url", url: "https://example.test/authorize" });
return {
type: "oauth",
access: `access-${method}`,
refresh: "refresh",
expires: Date.now() + 3_600_000,
};
},
},
});
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual([]);
expect(terminal.opened).toEqual([]);
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-browser" });
});
test("asks for selections during API key setup", async () => {
const { credentials, modelRuntime } = await setup({
apiKey: {
...bothMethods.apiKey!,
login: async (interaction) => ({
type: "api_key",
key: await interaction.prompt({
type: "select",
message: "Choose credentials:",
options: [
{ id: "first", label: "First" },
{ id: "second", label: "Second", description: "Other credentials" },
],
}),
}),
},
});
const terminal = scriptedTerminal(["2"]);
expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual(["Select 1-2: "]);
expect(terminal.lines).toContain(" 2. Second - Other credentials");
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "second" });
});
test("cancels a pending prompt without cancelling the login when the browser callback wins", async () => {
const promptController = new AbortController();
const { credentials, modelRuntime } = await setup({
oauth: {
...bothMethods.oauth!,
login: async (interaction) => {
const pending = interaction.prompt({
type: "manual_code",
message: "Paste the code",
placeholder: "redirect URL",
signal: promptController.signal,
});
promptController.abort();
await expect(pending).rejects.toBe(promptController.signal.reason);
expect(interaction.signal.aborted).toBe(false);
return { type: "oauth", access: "callback", refresh: "refresh", expires: Date.now() + 3_600_000 };
},
},
});
const terminal = scriptedTerminal([]);
terminal.question = vi.fn(
(_message: string, signal: AbortSignal) =>
new Promise<string>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}),
);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0);
expect(terminal.question).toHaveBeenCalledWith("Paste the code (redirect URL): ", expect.any(AbortSignal));
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "callback" });
});
test("reports a committed login separately from a failed login", async () => {
const { modelRuntime } = await setup(bothMethods);
vi.spyOn(modelRuntime, "login").mockRejectedValue(
new CredentialSynchronizationError(
PROVIDER_ID,
"login",
{ type: "api_key", key: "saved" },
{ cause: new Error("snapshot failed") },
),
);
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1);
expect(terminal.errors[0]).toContain("Logged in to Login Test, but local model state could not be synchronized:");
});
test("--api-key saves an API key", async () => {
const { credentials, modelRuntime } = await setup(bothMethods);
const terminal = scriptedTerminal(["sk-test"]);
expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual(["Enter Login Test API key: "]);
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" });
expect(terminal.lines.some((line) => line.startsWith("Saved API key for Login Test."))).toBe(true);
});
test("uses the API key login of providers without account sign-in", async () => {
const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey });
const terminal = scriptedTerminal(["sk-test"]);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0);
expect(terminal.questions).toEqual(["Enter Login Test API key: "]);
});
test("--api-key fails for providers without API key login", async () => {
const { modelRuntime } = await setup({ oauth: bothMethods.oauth });
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(1);
expect(terminal.errors).toEqual(["Login Test has no API key login. Omit --api-key to sign in."]);
});
test("reports providers configured outside pi", async () => {
const { modelRuntime } = await setup({ apiKey: { name: "AWS profile", resolve: async () => undefined } });
const terminal = scriptedTerminal([]);
expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1);
expect(terminal.errors).toEqual(["Login Test is configured outside pi (AWS profile)."]);
});
test("Ctrl+C during the catalog refresh skips it and keeps the login", async () => {
const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey });
const controller = new AbortController();
// A refresh that only ends when aborted, interrupted as soon as it starts.
vi.spyOn(modelRuntime, "refresh").mockImplementation(
({ signal } = {}) =>
new Promise((resolve) => {
signal?.addEventListener("abort", () => resolve({ aborted: true, errors: new Map() }));
controller.abort();
}),
);
const terminal = scriptedTerminal(["sk-test"]);
expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(0);
expect(terminal.lines).toContain("Skipped the Login Test model catalog refresh; pi refreshes it on next start.");
expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" });
});
test("reports cancellation", async () => {
const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey });
const controller = new AbortController();
controller.abort();
const terminal = scriptedTerminal(["sk-test"]);
expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(1);
expect(terminal.errors).toEqual(["Login cancelled."]);
expect(await credentials.read(PROVIDER_ID)).toBeUndefined();
});
test.each(["radius-mcp", "radius_mcp"])("keeps unrelated MCP servers when %s is occupied", async (fallback) => {
const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-"));
try {
const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius");
const mcpPath = join(agentDir, "mcp.json");
const servers = {
radius: { command: "original-one" },
[fallback]: { command: "original-two" },
"radius-mcp-2": { command: "original-three" },
};
writeFileSync(mcpPath, JSON.stringify({ mcpServers: servers }));
const terminal = scriptedTerminal(["sk-test"]);
expect(await run(["radius"], modelRuntime, terminal, undefined, agentDir)).toBe(0);
expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers).toEqual({
...servers,
"radius-mcp-3": { url: "https://radius.pi.dev/mcp", auth: { provider: "radius" } },
});
expect(terminal.lines).toContain(`Added MCP server "radius-mcp-3" in ${mcpPath} to use the Login Test login.`);
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("configures the Radius MCP server without asking, once", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-"));
try {
const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius");
const mcpPath = join(agentDir, "mcp.json");
const first = scriptedTerminal(["sk-test"]);
expect(await run(["radius"], modelRuntime, first, undefined, agentDir)).toBe(0);
expect(first.questions).toEqual(["Enter Login Test API key: "]);
expect(first.lines).toContain(`Added MCP server "radius" in ${mcpPath} to use the Login Test login.`);
expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers.radius).toMatchObject({
auth: { provider: "radius" },
});
const second = scriptedTerminal(["sk-test"]);
expect(await run(["radius"], modelRuntime, second, undefined, agentDir)).toBe(0);
expect(second.lines.some((line) => line.includes("MCP server"))).toBe(false);
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,99 @@
import { type Credential, InMemoryModelsStore } from "@earendil-works/pi-ai";
import { describe, expect, test, vi } from "vitest";
import { runAuthLogout } from "../src/cli/auth-logout.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts";
async function setup(data: Record<string, Credential>) {
const credentials = AuthStorage.inMemory(data);
const modelRuntime = await ModelRuntime.create({
credentials,
modelsPath: null,
modelsStore: new InMemoryModelsStore(),
allowModelNetwork: false,
});
const lines: string[] = [];
const errors: string[] = [];
const run = (args: string[]) =>
runAuthLogout(args, {
modelRuntime,
terminal: { write: (line) => lines.push(line), error: (line) => errors.push(line) },
signal: new AbortController().signal,
});
return { credentials, modelRuntime, lines, errors, run };
}
const oauthCredential = {
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 3_600_000,
} as const;
describe("pi auth logout", () => {
test("requires exactly one provider", async () => {
const { errors, run } = await setup({});
for (const args of [[], ["--api-key"], ["radius", "extra"]]) {
errors.length = 0;
expect(await run(args)).toBe(1);
expect(errors).toEqual(["Usage: pi auth logout <provider>"]);
}
});
test("removes a stored account login", async () => {
const { credentials, lines, run } = await setup({ radius: oauthCredential });
expect(await run(["radius"])).toBe(0);
expect(lines).toEqual(["Logged out of Radius."]);
expect(await credentials.read("radius")).toBeUndefined();
});
test("removes a stored API key by provider name and keeps other credentials", async () => {
const { credentials, lines, run } = await setup({
openai: { type: "api_key", key: "sk-test" },
radius: oauthCredential,
});
expect(await run(["OpenAI"])).toBe(0);
expect(lines).toEqual([
"Removed stored API key for OpenAI. Environment variables and models.json config are unchanged.",
]);
expect(await credentials.read("openai")).toBeUndefined();
expect(await credentials.read("radius")).toMatchObject({ type: "oauth" });
});
test("succeeds when nothing is stored, so it can be repeated", async () => {
const { lines, run } = await setup({});
expect(await run(["radius"])).toBe(0);
expect(lines).toEqual([
"No stored credentials for Radius. Environment variables and models.json config are unchanged.",
]);
});
test("removes credentials of providers that are not loaded, matched by id", async () => {
const { credentials, lines, run } = await setup({ "extension-provider": oauthCredential });
expect(await run(["extension-provider"])).toBe(0);
expect(lines).toEqual(["Logged out of extension-provider."]);
expect(await credentials.read("extension-provider")).toBeUndefined();
});
test("reports credential removal failures", async () => {
const { modelRuntime, errors, run } = await setup({ radius: oauthCredential });
vi.spyOn(modelRuntime, "logout").mockRejectedValue(new Error("storage failed"));
expect(await run(["radius"])).toBe(1);
expect(errors).toEqual(["Logout failed: storage failed"]);
});
test("reports committed credential removal separately from a failed logout", async () => {
const { modelRuntime, errors, run } = await setup({ radius: oauthCredential });
vi.spyOn(modelRuntime, "logout").mockRejectedValue(
new CredentialSynchronizationError("radius", "logout", undefined, { cause: new Error("snapshot failed") }),
);
expect(await run(["radius"])).toBe(1);
expect(errors[0]).toContain("Credentials removed for Radius, but local model state could not be synchronized:");
});
test("rejects an unknown provider and lists the stored credentials", async () => {
const { errors, run } = await setup({ radius: oauthCredential });
expect(await run(["nope"])).toBe(1);
expect(errors).toEqual(['Unknown provider "nope".', "Stored credentials: radius"]);
});
});

View file

@ -0,0 +1,63 @@
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createProcessLoginTerminal } from "../src/cli/auth-terminal.ts";
const inputs: PassThrough[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const input of inputs) input.destroy();
inputs.length = 0;
});
function setup() {
const input = new PassThrough();
inputs.push(input);
// Readline only needs the readable stream; the test does not exercise TTY detection or raw mode.
vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin);
vi.spyOn(process.stdout, "write").mockReturnValue(true);
const controller = new AbortController();
const interrupt = vi.fn(() => controller.abort());
const terminal = createProcessLoginTerminal(interrupt);
return { input, controller, interrupt, terminal };
}
describe("auth terminal questions", () => {
test("rejects and cancels the login when stdin closes before an answer", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
input.end();
await rejected;
expect(interrupt).toHaveBeenCalledOnce();
expect(controller.signal.aborted).toBe(true);
});
test("normal cleanup does not cancel the login after an answer", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
input.write("example.com\n");
await expect(answer).resolves.toBe("example.com");
expect(interrupt).not.toHaveBeenCalled();
expect(controller.signal.aborted).toBe(false);
});
test("keeps an answer delivered immediately before EOF", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
input.end("example.com\n");
await expect(answer).resolves.toBe("example.com");
expect(interrupt).not.toHaveBeenCalled();
});
test("prompt cancellation does not cancel the whole login", async () => {
const { controller, interrupt, terminal } = setup();
const prompt = new AbortController();
const answer = terminal.question("Paste code: ", AbortSignal.any([controller.signal, prompt.signal]));
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
prompt.abort();
await rejected;
expect(interrupt).not.toHaveBeenCalled();
expect(controller.signal.aborted).toBe(false);
});
});

View file

@ -110,7 +110,7 @@ describe("pi mcp", () => {
for (const command of ["login", "logout"]) {
expect(await run([command, "radius"], radius)).toMatchObject({
exitCode: 1,
output: `MCP server "radius" uses the credentials of provider "radius". Run /${command} radius in pi.`,
output: `MCP server "radius" uses the credentials of provider "radius". Run "pi auth ${command} radius".`,
});
}
} finally {

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { findMatchingProviders } from "../src/core/provider-matching.ts";
const providers = [
{ id: "first", name: "Shared Name", authType: "oauth" },
{ id: "first", name: "Shared Name", authType: "api_key" },
{ id: "other", name: "FIRST", authType: "api_key" },
{ id: "second", name: "Second Provider", authType: "oauth" },
];
describe("provider reference matching", () => {
test("matches IDs and display names case-insensitively with surrounding whitespace", () => {
expect(findMatchingProviders(providers, " sEcOnD ")).toEqual([providers[3]]);
expect(findMatchingProviders(providers, "\tSECOND PROVIDER\n")).toEqual([providers[3]]);
});
test("keeps all matching providers and auth methods in input order", () => {
const matched = findMatchingProviders(providers, "first");
expect(matched).toEqual(providers.slice(0, 3));
expect(matched[0]).toBe(providers[0]);
expect(matched[1]?.authType).toBe("api_key");
expect(findMatchingProviders(providers, "shared name")).toEqual(providers.slice(0, 2));
});
test("does not fuzzy-match unknown or partial references", () => {
expect(findMatchingProviders(providers, "unknown")).toEqual([]);
expect(findMatchingProviders(providers, "sec")).toEqual([]);
expect(findMatchingProviders([], "first")).toEqual([]);
});
test("never matches an empty reference, even against empty display names", () => {
expect(findMatchingProviders([{ id: "unnamed", name: "" }], "")).toEqual([]);
expect(findMatchingProviders(providers, " \t ")).toEqual([]);
});
});

View file

@ -0,0 +1,79 @@
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, test, vi } from "vitest";
import { questionTerminal } from "../src/utils/terminal-question.ts";
const streams: PassThrough[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const stream of streams) stream.destroy();
streams.length = 0;
});
function setup() {
const input = new PassThrough();
const output = new PassThrough();
streams.push(input, output);
// Readline only needs the stream; real terminal capabilities are added by the Ctrl+C test below.
vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin);
const controller = new AbortController();
const onInterrupt = vi.fn();
return { input, output, controller, onInterrupt };
}
describe("terminal questions", () => {
test("writes to the selected output and cleans up after an answer", async () => {
const { input, output, controller, onInterrupt } = setup();
const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt });
expect(output.read()?.toString()).toBe("Paste URL: ");
input.write("https://example.test/callback\n");
await expect(answer).resolves.toBe("https://example.test/callback");
expect(onInterrupt).not.toHaveBeenCalled();
expect(input.listenerCount("data")).toBe(0);
expect(input.isPaused()).toBe(true);
});
test("rejects on EOF without requiring an interrupt callback", async () => {
const { input, output, controller } = setup();
const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal });
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
input.end();
await rejected;
expect(input.listenerCount("data")).toBe(0);
});
test("Ctrl+C rejects the question and reports an interrupt", async () => {
const { input, output, controller, onInterrupt } = setup();
const setRawMode = vi.fn();
Object.assign(input, { setRawMode });
Object.assign(output, { isTTY: true });
const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt });
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
input.write("\u0003");
await rejected;
expect(onInterrupt).toHaveBeenCalledOnce();
expect(input.listenerCount("keypress")).toBe(0);
expect(output.listenerCount("resize")).toBe(0);
expect(setRawMode).toHaveBeenLastCalledWith(false);
});
test("caller cancellation does not report an interrupt", async () => {
const { input, output, controller, onInterrupt } = setup();
const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt });
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
controller.abort();
await rejected;
expect(onInterrupt).not.toHaveBeenCalled();
expect(input.listenerCount("data")).toBe(0);
});
test("rejects an already-aborted prompt and cleans up", async () => {
const { input, output, controller, onInterrupt } = setup();
controller.abort();
await expect(
questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }),
).rejects.toMatchObject({ name: "AbortError" });
expect(onInterrupt).not.toHaveBeenCalled();
expect(input.listenerCount("data")).toBe(0);
});
});