feat(coding-agent): offer Radius sign-in and MCP setup in /login

The top-level /login menu offers Sign in with Radius with its status. After a Radius sign-in it offers to configure the Radius MCP server in the global mcp.json with auth.provider and reloads. Cancelling a login returns to the menu it was started from. OAuth sign-ins without a subscription are labeled as accounts.
This commit is contained in:
Armin Ronacher 2026-10-01 13:11:10 +02:00
parent e792ba131e
commit 04c8ea9dbe
5 changed files with 158 additions and 46 deletions

View file

@ -8,6 +8,7 @@
### Changed
- `/login` now offers "Sign in with Radius" at the top level, as the last option, with its status. After a Radius sign-in, `/login` offers to configure the Radius MCP server in the global `mcp.json` with `"auth": { "provider": "radius" }` and reloads. Cancelling a login returns to the menu it was started from.
- MCP OAuth credentials are now stored per server name and URL, so MCP servers with the same URL can sign in with different accounts. Credentials stored by URL alone move to the first server that uses them ([#10252](https://github.com/earendil-works/pi/issues/10252)).
### Fixed
@ -18,6 +19,7 @@
- Fixed `--provider` without `--model` being silently ignored and running the default model from another provider; it now fails with an error ([#10236](https://github.com/earendil-works/pi/issues/10236)).
- Fixed MCP servers that ask for more scope (`insufficient_scope`) requesting sign-in over and over. The new sign-in requested only the missing scopes, so the new token lost access the previous one had; it now keeps the granted scopes.
- Fixed user messages in the transcript keeping two full-width copies of every rendered line; they keep one, with identical output.
- Fixed `/login` and `/logout` labeling every OAuth sign-in, including Radius, as a subscription; only subscription-backed providers say "subscription", other OAuth sign-ins say "account".
## [0.99.2] - 2026-09-30

View file

@ -2,6 +2,8 @@ import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-wor
export const RADIUS_PROVIDER_ID = "radius";
export const ENV_RADIUS_GATEWAY = "PI_RADIUS_GATEWAY";
/** MCP endpoint of the gateway the built-in Radius provider signs in to. */
export const RADIUS_MCP_URL = `${normalizeRadiusGatewayUrl(DEFAULT_RADIUS_GATEWAY)}/mcp`;
/** Radius gateway origin, honoring the `PI_RADIUS_GATEWAY` override. */
export function getRadiusGatewayUrl(): string {

View file

@ -17,10 +17,36 @@ export type AuthSelectorProvider = {
authType: "oauth" | "api_key";
method?: ApiKeyAuth | OAuthAuth;
status?: AuthCheck;
/** Whether the provider's OAuth sign-in is backed by a subscription. Other OAuth sign-ins are accounts. */
subscription?: boolean;
};
export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string {
return authType === "oauth" ? "subscription" : "API key";
export function formatAuthSelectorProviderType(
authType: AuthSelectorProvider["authType"],
subscription: boolean | undefined,
): string {
if (authType === "api_key") return "API key";
return subscription ? "subscription" : "account";
}
/** Themed suffix describing whether and how a login option is configured, for example " ✓ configured". */
export function formatAuthSelectorProviderStatus(provider: AuthSelectorProvider): string {
if (!provider.status) return theme.fg("muted", " • unconfigured");
if (provider.status.type !== provider.authType) {
const label = `${formatAuthSelectorProviderType(provider.status.type, provider.subscription)} configured`;
return theme.fg("muted", " • ") + theme.fg("warning", label);
}
if (
!provider.status.source ||
provider.status.source === "OAuth" ||
provider.status.source === "stored credential"
) {
return theme.fg("success", " ✓ configured");
}
const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source)
? `env: ${provider.status.source}`
: provider.status.source;
return theme.fg("success", ` ✓ ${source}`);
}
/**
@ -127,9 +153,9 @@ export class OAuthSelectorComponent extends Container implements Focusable {
const isSelected = i === this.selectedIndex;
const statusIndicator = this.formatStatusIndicator(provider);
const statusIndicator = formatAuthSelectorProviderStatus(provider);
const authTypeLabel = this.showAuthTypeLabels
? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType)}]`)
? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType, provider.subscription)}]`)
: "";
let line = "";
if (isSelected) {
@ -161,25 +187,6 @@ export class OAuthSelectorComponent extends Container implements Focusable {
}
}
private formatStatusIndicator(provider: AuthSelectorProvider): string {
if (!provider.status) return theme.fg("muted", " • unconfigured");
if (provider.status.type !== provider.authType) {
const label = provider.status.type === "oauth" ? "subscription configured" : "API key configured";
return theme.fg("muted", " • ") + theme.fg("warning", label);
}
if (
!provider.status.source ||
provider.status.source === "OAuth" ||
provider.status.source === "stored credential"
) {
return theme.fg("success", " ✓ configured");
}
const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source)
? `env: ${provider.status.source}`
: provider.status.source;
return theme.fg("success", ` ✓ ${source}`);
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow

View file

@ -94,6 +94,7 @@ 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,
@ -102,6 +103,7 @@ 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 type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import {
@ -118,6 +120,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 { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@ -151,6 +154,7 @@ import { createMermaidMarkdownTransformer } from "./components/mermaid.ts";
import { ModelSelectorComponent } from "./components/model-selector.ts";
import {
type AuthSelectorProvider,
formatAuthSelectorProviderStatus,
formatAuthSelectorProviderType,
OAuthSelectorComponent,
} from "./components/oauth-selector.ts";
@ -356,6 +360,7 @@ type LoginProviderCompletionOption = {
id: string;
name: string;
authTypes: AuthSelectorProvider["authType"][];
subscription?: boolean;
};
const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>;
@ -388,6 +393,7 @@ function getLoginProviderCompletionOptions(
id: provider.id,
name: provider.name,
authTypes: [provider.authType],
subscription: provider.subscription,
});
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));
@ -395,13 +401,15 @@ function getLoginProviderCompletionOptions(
function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string {
const authTypes = provider.authTypes
.map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`)
.map((authType) => `${authType} ${formatAuthSelectorProviderType(authType, provider.subscription)}`)
.join(" ");
return `${provider.id} ${provider.name} ${authTypes}`;
}
function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string {
const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
const authTypes = provider.authTypes
.map((authType) => formatAuthSelectorProviderType(authType, provider.subscription))
.join("/");
return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
}
@ -5691,6 +5699,7 @@ export class InteractiveMode {
source: authStatus.label ?? authStatus.source,
}
: undefined;
const subscription = provider.auth.oauth?.isSubscription === true;
if ((!authType || authType === "oauth") && provider.auth.oauth) {
options.push({
id: provider.id,
@ -5698,6 +5707,7 @@ export class InteractiveMode {
authType: "oauth",
method: provider.auth.oauth,
status,
subscription,
});
}
if ((!authType || authType === "api_key") && provider.auth.apiKey) {
@ -5707,6 +5717,7 @@ export class InteractiveMode {
authType: "api_key",
method: provider.auth.apiKey,
status,
subscription,
});
}
}
@ -5715,12 +5726,16 @@ export class InteractiveMode {
private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> {
return (await this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) }))
.map(({ providerId, type }) => ({
id: providerId,
name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId,
authType: type,
status: { type, source: "stored credential" },
}))
.map(({ providerId, type }) => {
const provider = this.session.modelRuntime.getProvider(providerId);
return {
id: providerId,
name: provider?.name ?? providerId,
authType: type,
status: { type, source: "stored credential" },
subscription: provider?.auth.oauth?.isSubscription === true,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
@ -5760,17 +5775,25 @@ export class InteractiveMode {
this.showLoginProviderSelector(undefined, providerRef);
}
private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> {
/** `onBack` reopens the selector the login was started from when the user cancels it. */
private async startProviderLogin(providerOption: AuthSelectorProvider, onBack?: () => void): Promise<void> {
if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name);
await this.showLoginDialog(providerOption.id, providerOption.name, onBack);
} else if (providerOption.method?.login) {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name, onBack);
} else {
this.showAmbientAuthDialog(providerOption);
this.showAmbientAuthDialog(providerOption, onBack);
}
}
private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void {
// The top-level selector offers Radius directly, as its last option.
const radiusOption = providerOptions
? undefined
: this.getLoginProviderOptions("oauth").find((provider) => provider.id === RADIUS_PROVIDER_ID);
const radiusLabel = radiusOption
? `Sign in with ${radiusOption.name}${formatAuthSelectorProviderStatus(radiusOption)}`
: undefined;
const oauthProvider = providerOptions?.find((provider) => provider.authType === "oauth");
const oauthLoginLabel =
oauthProvider?.method && "loginLabel" in oauthProvider.method ? oauthProvider.method.loginLabel : undefined;
@ -5786,6 +5809,7 @@ export class InteractiveMode {
if (availableAuthTypes.has("api_key")) {
options.push(apiKeyLabel);
}
if (radiusLabel) options.push(radiusLabel);
if (options.length === 0) {
this.showStatus("No login methods available.");
@ -5809,11 +5833,17 @@ export class InteractiveMode {
options,
(option) => {
done();
if (radiusOption && option === radiusLabel) {
void this.startProviderLogin(radiusOption, () => this.showLoginAuthTypeSelector());
return;
}
const authType = option === subscriptionLabel ? "oauth" : "api_key";
if (providerOptions) {
const providerOption = providerOptions.find((provider) => provider.authType === authType);
if (providerOption) {
void this.startProviderLogin(providerOption);
void this.startProviderLogin(providerOption, () =>
this.showLoginAuthTypeSelector(providerOptions),
);
}
return;
}
@ -5833,7 +5863,7 @@ export class InteractiveMode {
if (providerOptions.length === 0) {
const message =
authType === "oauth"
? "No subscription providers available."
? "No account providers available."
: authType === "api_key"
? "No API key providers available."
: "No login providers available.";
@ -5855,7 +5885,9 @@ export class InteractiveMode {
return;
}
await this.startProviderLogin(providerOption);
await this.startProviderLogin(providerOption, () =>
this.showLoginProviderSelector(authType, initialSearchInput),
);
},
() => {
done();
@ -6028,7 +6060,7 @@ export class InteractiveMode {
.finally(() => clearTimeout(timeout));
}
private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void {
private showAmbientAuthDialog(providerOption: AuthSelectorProvider, onBack?: () => void): void {
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
@ -6039,7 +6071,10 @@ export class InteractiveMode {
const dialog = new LoginDialogComponent(
this.ui,
providerOption.id,
() => restoreEditor(),
() => {
restoreEditor();
onBack?.();
},
providerOption.name,
`${providerOption.name} setup`,
);
@ -6055,7 +6090,7 @@ export class InteractiveMode {
this.ui.requestRender();
}
private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> {
private async showApiKeyLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise<void> {
const previousModel = this.session.model;
const dialog = new LoginDialogComponent(
@ -6098,7 +6133,9 @@ export class InteractiveMode {
this.showError(
`Saved API key for ${providerName}, but local model state could not be synchronized: ${errorMsg}`,
);
} else if (errorMsg !== "Login cancelled") {
} else if (errorMsg === "Login cancelled") {
onBack?.();
} else {
this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`);
}
}
@ -6191,7 +6228,7 @@ export class InteractiveMode {
);
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
private async showLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise<void> {
const previousModel = this.session.model;
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName);
this.editorContainer.clear();
@ -6210,6 +6247,7 @@ export class InteractiveMode {
await this.loginProvider(dialog, providerId, "oauth");
restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel);
if (providerId === RADIUS_PROVIDER_ID) this.offerRadiusMcpServer(providerId, providerName);
} catch (error: unknown) {
restoreEditor();
const errorMsg = error instanceof Error ? error.message : String(error);
@ -6217,12 +6255,67 @@ export class InteractiveMode {
this.showError(
`Logged in to ${providerName}, but local model state could not be synchronized: ${errorMsg}`,
);
} else if (errorMsg !== "Login cancelled") {
} else if (errorMsg === "Login cancelled") {
onBack?.();
} else {
this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
}
}
}
/**
* Offer to point the Radius MCP server in the global mcp.json at the Radius login, adding the server
* 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;
this.showSelector((done) => {
const selector = new ExtensionSelectorComponent(
`Configure ${providerName} MCP in ${mcpPath}?`,
["Yes", "No"],
(option) => {
done();
if (option !== "Yes") return;
try {
addMcpServerConfig(mcpPath, name, config);
} catch (error: unknown) {
this.showError(
`Could not update ${mcpPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
// The MCP extension reads mcp.json when the session starts.
void this.handleReloadCommand();
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
// =========================================================================
// Command handlers
// =========================================================================

View file

@ -486,9 +486,10 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
sessionManager: { getCwd: () => "/tmp" },
fdPath: null,
getLoginProviderOptions: () => [
{ id: "anthropic", name: "Anthropic", authType: "oauth" },
{ id: "anthropic", name: "Anthropic", authType: "api_key" },
{ id: "anthropic", name: "Anthropic", authType: "oauth", subscription: true },
{ id: "anthropic", name: "Anthropic", authType: "api_key", subscription: true },
{ id: "openai", name: "OpenAI", authType: "api_key" },
{ id: "radius", name: "Radius", authType: "oauth", subscription: false },
],
};
@ -505,6 +506,13 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
description: "Anthropic · subscription/API key",
},
]);
// OAuth sign-in without a subscription, such as Radius, is an account.
const radiusLine = "/login radius";
const radiusSuggestions = await provider.getSuggestions([radiusLine], 0, radiusLine.length, {
signal: new AbortController().signal,
});
expect(radiusSuggestions?.items).toEqual([{ value: "radius", label: "radius", description: "Radius · account" }]);
});
});
describe("InteractiveMode.showLoadedResources", () => {