refactor(auth-broker): migrated OAuth flow from pi-ai CLI to AuthStorage

- Migrated OAuth provider authentication from standalone `pi-ai` CLI to in-process `AuthStorage.login()` flow in coding-agent.
- Made provider argument optional for `login` and `logout` commands with interactive provider picker when omitted.
- Added `list` command to enumerate registered OAuth providers with optional `--json` output format.
- Removed `pi-ai` CLI binary and `bin` entry from @oh-my-pi/ai package; library API remains unchanged.
- Updated documentation and examples to reflect new `omp auth-broker` command interface and in-process OAuth flow.
This commit is contained in:
can1357
2026-05-26 19:56:43 +02:00
parent 664a47dba9
commit 07d13ba15e
8 changed files with 203 additions and 296 deletions
+6 -4
View File
@@ -47,8 +47,9 @@ The broker is the only writer of OAuth refresh tokens. Clients (including the ga
```
omp auth-broker serve [--bind=host:port] # boot the broker
omp auth-broker token [--regenerate] [--json] # print or rotate the bearer token
omp auth-broker login <provider> [--via=user@host] [--dry-run]
omp auth-broker logout <provider>
omp auth-broker login [<provider>] [--via=user@host] [--dry-run]
omp auth-broker logout [<provider>]
omp auth-broker list [--json]
omp auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run] [--json]
omp auth-broker migrate --from-local [--dry-run] [--json]
omp auth-broker status [--json]
@@ -56,8 +57,9 @@ omp auth-broker status [--json]
- `serve` opens the local SQLite store at `getAgentDbPath()` and binds an HTTP listener (default `127.0.0.1:8765`). On startup a token is ensured at `<config-dir>/auth-broker.token` (mode `0600`, `0700` parent dir). The background refresher refreshes any OAuth credential whose `expires - Date.now() < refreshSkewMs` (default 5 min) every `refreshIntervalMs` (default 60 s).
- `token` prints the cached bearer or generates a new one. `--regenerate` rotates it.
- `login <provider>` runs the per-provider OAuth flow locally, or — with `--via=user@host` — `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host omp auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host. Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`.
- `logout <provider>` deletes every credential row for `<provider>`.
- `login [<provider>]` runs the per-provider OAuth flow locally — when no provider is supplied, it falls back to an interactive numbered picker. With `--via=user@host` it shells out `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host omp auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host (`--via` requires `<provider>`). Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`. The OAuth dance is driven in-process via `AuthStorage.login()` — there is no longer a `pi-ai` bin to spawn.
- `logout [<provider>]` deletes every credential row for `<provider>`. With no argument it shows an interactive numbered picker of currently-stored providers.
- `list` enumerates every registered OAuth provider id/name (the union of built-ins + `registerOAuthProvider` custom providers). `--json` emits a machine-readable array.
- `import <file|dir>` imports CLIProxyAPI-style JSON credentials into the local SQLite store. Maps `type` field → omp provider (`claude → anthropic`, `codex → openai-codex`, `gemini → google-gemini-cli`, `antigravity → google-antigravity`, `gemini-cli → google-gemini-cli`).
- `migrate --from-local` walks the local SQLite store + env-derived credentials and idempotently uploads them to the configured broker (`POST /v1/credential`).
- `status` health-pings the configured remote broker.
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Removed
- Removed the `pi-ai` CLI binary (`packages/ai/src/cli.ts`) and its `bin` entry. Use the in-process equivalent in the omp coding-agent CLI: `omp auth-broker login [provider]`, `omp auth-broker logout [provider]`, and `omp auth-broker list`. The library API (`AuthStorage.login()`, `getOAuthProviders()`, etc.) is unchanged.
## [15.4.3] - 2026-05-26
### Fixed
+6 -5
View File
@@ -1057,13 +1057,14 @@ Official docs: [Application Default Credentials](https://cloud.google.com/docs/a
### CLI Login
The quickest way to authenticate:
Authenticate via the [`omp`](https://omp.sh) coding-agent CLI, which drives this library's OAuth/API-key flows in-process and persists into `agent.db`:
```bash
bunx @oh-my-pi/pi-ai login # interactive provider selection
bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
bunx @oh-my-pi/pi-ai login vllm # store vLLM API key (or placeholder for local no-auth)
bunx @oh-my-pi/pi-ai list # list available providers
omp auth-broker login # interactive provider selection
omp auth-broker login anthropic # login to a specific provider
omp auth-broker login vllm # store vLLM API key (or placeholder for local no-auth)
omp auth-broker list # list supported providers
omp auth-broker logout # interactive — pick a stored credential to remove
```
Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
-3
View File
@@ -28,9 +28,6 @@
],
"main": "./src/index.ts",
"types": "./src/index.ts",
"bin": {
"pi-ai": "./src/cli.ts"
},
"scripts": {
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env bun
import * as readline from "node:readline";
import { AuthStorage, SqliteAuthCredentialStore } from "./auth-storage";
import { getOAuthProviders } from "./utils/oauth";
import type { OAuthProvider } from "./utils/oauth/types";
const PROVIDERS = getOAuthProviders();
function prompt(rl: readline.Interface, question: string): Promise<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
const input = process.stdin as NodeJS.ReadStream;
const supportsRawMode = input.isTTY && typeof input.setRawMode === "function";
const wasRaw = supportsRawMode ? input.isRaw : false;
let settled = false;
const cleanup = () => {
rl.off("SIGINT", onSigint);
if (supportsRawMode) {
input.off("keypress", onKeypress);
input.setRawMode?.(wasRaw);
}
};
const finish = (result: () => void) => {
if (settled) return;
settled = true;
cleanup();
result();
};
const cancel = () => {
finish(() => reject(new Error("Login cancelled")));
};
const onSigint = () => {
cancel();
};
const onKeypress = (_str: string, key: readline.Key) => {
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cancel();
rl.close();
}
};
if (supportsRawMode) {
readline.emitKeypressEvents(input, rl);
input.setRawMode(true);
input.on("keypress", onKeypress);
}
rl.once("SIGINT", onSigint);
rl.question(question, answer => {
finish(() => resolve(answer));
});
return promise;
}
async function login(provider: OAuthProvider): Promise<void> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
const store = await SqliteAuthCredentialStore.open();
const storage = new AuthStorage(store);
await storage.reload();
try {
await storage.login(provider, {
onAuth(info) {
const { url, instructions } = info;
console.log(`\nOpen this URL in your browser:\n${url}`);
if (instructions) console.log(instructions);
console.log();
},
onProgress(message) {
console.log(message);
},
onPrompt(p) {
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
});
console.log(`\nCredentials saved to ~/.omp/agent/agent.db`);
} finally {
store.close();
rl.close();
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") {
console.log(`Usage: bunx @oh-my-pi/pi-ai <command> [provider]
Commands:
login [provider] Login to a provider
logout [provider] Logout from a provider
status Show logged-in providers
list List available providers
Providers:
anthropic Anthropic (Claude Pro/Max)
github-copilot GitHub Copilot
google-gemini-cli Google Gemini CLI
google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
openai-codex OpenAI Codex (ChatGPT Plus/Pro)
kimi-code Kimi Code
kilo Kilo Gateway
kagi Kagi
tavily Tavily
zai Z.AI (GLM Coding Plan)
deepseek DeepSeek
nanogpt NanoGPT
minimax-code MiniMax Coding Plan (International)
minimax-code-cn MiniMax Coding Plan (China)
cursor Cursor (Claude, GPT, etc.)
zenmux ZenMux
ollama-cloud Ollama Cloud
Examples:
bunx @oh-my-pi/pi-ai login # interactive provider selection
bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
bunx @oh-my-pi/pi-ai logout anthropic # logout from specific provider
bunx @oh-my-pi/pi-ai status # show logged-in providers
bunx @oh-my-pi/pi-ai list # list providers
`);
return;
}
if (command === "status") {
const storage = await SqliteAuthCredentialStore.open();
try {
const providers = storage.listProviders();
if (providers.length === 0) {
console.log("No credentials stored.");
console.log(`Use 'bunx @oh-my-pi/pi-ai login' to authenticate.`);
} else {
console.log("Logged-in providers:\n");
for (const provider of providers) {
const oauth = storage.getOAuth(provider);
if (oauth) {
const expires = new Date(oauth.expires);
const expired = Date.now() >= oauth.expires;
const status = expired ? "(expired)" : `(expires ${expires.toLocaleString()})`;
console.log(` ${provider.padEnd(20)} ${status}`);
continue;
}
const apiKey = storage.getApiKey(provider);
if (apiKey) {
console.log(` ${provider.padEnd(20)} (api key)`);
}
}
}
} finally {
storage.close();
}
return;
}
if (command === "list") {
console.log("Available providers:\n");
for (const p of PROVIDERS) {
console.log(` ${p.id.padEnd(20)} ${p.name}`);
}
return;
}
if (command === "logout") {
let provider = args[1] as OAuthProvider | undefined;
const storage = await SqliteAuthCredentialStore.open();
try {
if (!provider) {
const providers = storage.listProviders();
if (providers.length === 0) {
console.log("No credentials stored.");
return;
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider to logout:\n");
for (let i = 0; i < providers.length; i++) {
console.log(` ${i + 1}. ${providers[i]}`);
}
console.log();
const choice = await prompt(rl, `Enter number (1-${providers.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= providers.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = providers[index] as OAuthProvider;
}
if (!provider) {
console.error("No provider selected");
process.exit(1);
}
const oauth = storage.getOAuth(provider);
const apiKey = storage.getApiKey(provider);
if (!oauth && !apiKey) {
console.error(`Not logged in to ${provider}`);
process.exit(1);
}
storage.deleteProvider(provider);
console.log(`Logged out from ${provider}`);
} finally {
storage.close();
}
return;
}
if (command === "login") {
let provider = args[1] as OAuthProvider | undefined;
if (!provider) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider:\n");
for (let i = 0; i < PROVIDERS.length; i++) {
console.log(` ${i + 1}. ${PROVIDERS[i].name}`);
}
console.log();
const choice = await prompt(rl, `Enter number (1-${PROVIDERS.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= PROVIDERS.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = PROVIDERS[index].id as OAuthProvider;
}
if (!provider) {
console.error("No provider selected");
process.exit(1);
}
if (!PROVIDERS.some(p => p.id === provider)) {
console.error(`Unknown provider: ${provider}`);
console.error(`Use 'bunx @oh-my-pi/pi-ai list' to see available providers`);
process.exit(1);
}
console.log(`Logging in to ${provider}…`);
await login(provider);
return;
}
console.error(`Unknown command: ${command}`);
console.error(`Use 'bunx @oh-my-pi/pi-ai --help' for usage`);
process.exit(1);
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
+13
View File
@@ -1,6 +1,19 @@
# Changelog
## [Unreleased]
### Added
- Added interactive provider selection to `omp auth-broker logout` when no provider argument is supplied
- Added `--json` flag to `omp auth-broker list` for machine-readable output
- Added `omp auth-broker list` to enumerate supported OAuth providers (replaces `bunx @oh-my-pi/pi-ai list`).
- Added interactive provider selection to `omp auth-broker login` and `omp auth-broker logout` when no provider argument is supplied (replaces `bunx @oh-my-pi/pi-ai login` / `logout` interactive flows).
### Changed
- Changed `omp auth-broker login` to support interactive provider selection when invoked without a provider argument
- Changed `omp auth-broker logout` to support interactive provider selection from stored credentials when invoked without a provider argument
- Changed `omp auth-broker login` to drive the per-provider OAuth/API-key flow in-process via `AuthStorage.login()` instead of spawning the `pi-ai` CLI subprocess. The pi-ai bin is being removed; the same login surface now lives entirely inside `omp`.
- Changed default per-line truncation cap for search/grep output (`DEFAULT_MAX_COLUMN`) from `1024` to `512` characters.
## [15.4.3] - 2026-05-26
+171 -22
View File
@@ -17,6 +17,7 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as readline from "node:readline";
import {
AuthBrokerClient,
type AuthCredential,
@@ -28,6 +29,7 @@ import {
listProvidersWithEnvKey,
type OAuthCredential,
type OAuthProvider,
type OAuthProviderInfo,
SqliteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai";
@@ -36,7 +38,7 @@ import { $ } from "bun";
import chalk from "chalk";
import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate";
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate" | "list";
export interface AuthBrokerCommandArgs {
action: AuthBrokerAction;
@@ -60,7 +62,16 @@ export interface AuthBrokerCommandArgs {
};
}
const ACTIONS: readonly AuthBrokerAction[] = ["serve", "token", "login", "logout", "import", "migrate", "status"];
const ACTIONS: readonly AuthBrokerAction[] = [
"serve",
"token",
"login",
"logout",
"import",
"migrate",
"status",
"list",
];
/** Callback ports baked from the per-provider OAuth flow modules. */
const CALLBACK_PORTS: Record<string, number> = {
@@ -168,13 +179,23 @@ async function runToken(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providerArg = flags.provider;
const providers = getOAuthProviders();
let providerArg = flags.provider;
if (!providerArg) {
throw new Error("Usage: omp auth-broker login <provider> [--via=user@host]");
if (flags.via) {
throw new Error(
"Usage: omp auth-broker login <provider> --via=user@host (provider required for remote login)",
);
}
providerArg = await pickProviderInteractively(providers);
}
const oauthProviders = new Set<string>(getOAuthProviders().map(p => p.id));
if (!oauthProviders.has(providerArg)) {
throw new Error(`Unknown OAuth provider '${providerArg}'. Known: ${[...oauthProviders].sort().join(", ")}`);
if (!providers.some(p => p.id === providerArg)) {
throw new Error(
`Unknown OAuth provider '${providerArg}'. Known: ${providers
.map(p => p.id)
.sort()
.join(", ")}`,
);
}
if (flags.via) {
await runRemoteLogin(providerArg, flags.via, flags.dryRun ?? false);
@@ -184,18 +205,107 @@ async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
async function runLocalLogin(provider: OAuthProvider): Promise<void> {
// Spawn the pi-ai CLI in-process — it handles the per-provider OAuth dance
// and persists into the same SQLite store the broker uses.
const piAiCli = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-ai/cli"));
const proc = Bun.spawn({
cmd: [process.execPath, piAiCli, "login", provider],
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
// Drive the per-provider OAuth dance in-process. Persists into the same
// SQLite store the broker uses.
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (msg: string) => promptLine(rl, `${msg} `);
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
const storage = new AuthStorage(store);
await storage.reload();
try {
await storage.login(provider, {
onAuth({ url, instructions }) {
process.stdout.write(`\nOpen this URL in your browser:\n${url}\n`);
if (instructions) process.stdout.write(`${instructions}\n`);
process.stdout.write("\n");
},
onProgress(message) {
process.stdout.write(`${message}\n`);
},
onPrompt(p) {
return ask(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
});
process.stdout.write(`\nCredentials saved to ${getAgentDbPath()}\n`);
} finally {
store.close();
rl.close();
}
}
/**
* Interactive `readline` prompt that cleanly tears down on Ctrl-C / Escape so
* cancelling a half-finished login flow doesn't leave the terminal in raw mode.
*/
function promptLine(rl: readline.Interface, question: string): Promise<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
const input = process.stdin as NodeJS.ReadStream;
const supportsRawMode = input.isTTY && typeof input.setRawMode === "function";
const wasRaw = supportsRawMode ? input.isRaw : false;
let settled = false;
const cleanup = () => {
rl.off("SIGINT", onSigint);
if (supportsRawMode) {
input.off("keypress", onKeypress);
input.setRawMode?.(wasRaw);
}
};
const finish = (result: () => void) => {
if (settled) return;
settled = true;
cleanup();
result();
};
const cancel = () => {
finish(() => reject(new Error("Login cancelled")));
};
const onSigint = () => {
cancel();
};
const onKeypress = (_str: string, key: readline.Key) => {
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
cancel();
rl.close();
}
};
if (supportsRawMode) {
readline.emitKeypressEvents(input, rl);
input.setRawMode(true);
input.on("keypress", onKeypress);
}
rl.once("SIGINT", onSigint);
rl.question(question, answer => {
finish(() => resolve(answer));
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`pi-ai login exited with code ${exitCode}`);
return promise;
}
async function pickProviderInteractively(providers: readonly OAuthProviderInfo[]): Promise<string> {
if (providers.length === 0) {
throw new Error("No OAuth providers registered");
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
process.stdout.write("Select a provider:\n\n");
for (let i = 0; i < providers.length; i++) {
process.stdout.write(` ${i + 1}. ${providers[i].name}\n`);
}
process.stdout.write("\n");
const choice = await promptLine(rl, `Enter number (1-${providers.length}): `);
const index = Number.parseInt(choice, 10) - 1;
if (Number.isNaN(index) || index < 0 || index >= providers.length) {
throw new Error(`Invalid selection: ${choice}`);
}
return providers[index].id;
} finally {
rl.close();
}
}
@@ -235,12 +345,17 @@ async function runRemoteLogin(provider: string, via: string, dryRun: boolean): P
}
async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providerArg = flags.provider;
if (!providerArg) {
throw new Error("Usage: omp auth-broker logout <provider>");
}
let providerArg = flags.provider;
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
try {
if (!providerArg) {
const stored = store.listProviders();
if (stored.length === 0) {
process.stdout.write("No credentials stored.\n");
return;
}
providerArg = await pickStoredProviderInteractively(stored);
}
store.deleteAuthCredentialsForProvider(providerArg, "logged out by user");
process.stdout.write(`Logged out of ${providerArg}\n`);
} finally {
@@ -248,6 +363,37 @@ async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
}
}
async function pickStoredProviderInteractively(providers: string[]): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
process.stdout.write("Select a provider to logout:\n\n");
for (let i = 0; i < providers.length; i++) {
process.stdout.write(` ${i + 1}. ${providers[i]}\n`);
}
process.stdout.write("\n");
const choice = await promptLine(rl, `Enter number (1-${providers.length}): `);
const index = Number.parseInt(choice, 10) - 1;
if (Number.isNaN(index) || index < 0 || index >= providers.length) {
throw new Error(`Invalid selection: ${choice}`);
}
return providers[index];
} finally {
rl.close();
}
}
async function runList(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const providers = getOAuthProviders();
if (flags.json) {
process.stdout.write(`${JSON.stringify(providers.map(p => ({ id: p.id, name: p.name })))}\n`);
return;
}
process.stdout.write("Available providers:\n\n");
for (const p of providers) {
process.stdout.write(` ${p.id.padEnd(20)} ${p.name}\n`);
}
}
// ─── CLIProxyAPI import ─────────────────────────────────────────────────
/**
@@ -732,6 +878,9 @@ export async function runAuthBrokerCommand(cmd: AuthBrokerCommandArgs): Promise<
case "status":
await runStatus(cmd.flags);
return;
case "list":
await runList(cmd.flags);
return;
default: {
// Exhaustive check.
const _exhaustive: never = cmd.action;
@@ -56,8 +56,11 @@ export default class AuthBroker extends Command {
"# Boot on a non-default port\n omp auth-broker serve --bind=127.0.0.1:9000",
"# Print the bearer token\n omp auth-broker token",
"# Rotate the bearer token\n omp auth-broker token --regenerate",
"# List supported OAuth providers\n omp auth-broker list",
"# Local login (run on the broker host)\n omp auth-broker login anthropic",
"# Interactive provider selection\n omp auth-broker login",
"# Remote login over SSH tunnel\n omp auth-broker login anthropic --via=user@broker",
"# Log out of a provider (interactive without provider arg)\n omp auth-broker logout anthropic",
"# Import a CLIProxyAPI auth dump\n omp auth-broker import ~/.cliproxy/auth",
"# Import a single CLIProxyAPI JSON, overriding the provider mapping\n omp auth-broker import ~/.cliproxy/auth/claude-foo.json --provider anthropic",
"# Preview a migration from local store + env vars to the configured broker\n omp auth-broker migrate --from-local --include-env --dry-run",