co-mono/packages/coding-agent/src/main.ts
Mario Zechner d2f3b42deb fix: OAuth token refresh failure returns undefined instead of throwing
When OAuth refresh fails during model discovery, getApiKey() now returns
undefined instead of throwing. This allows the app to start and fall back
to other providers, so the user can /login to re-authenticate.

fixes #498
2026-01-06 20:57:42 +01:00

508 lines
16 KiB
TypeScript

/**
* Main entry point for the coding agent CLI.
*
* This file handles CLI argument parsing and translates them into
* createAgentSession() options. The SDK does the heavy lifting.
*/
import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
import { type Args, parseArgs, printHelp } from "./cli/args.js";
import { processFileArguments } from "./cli/file-processor.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import type { AgentSession } from "./core/agent-session.js";
import { createEventBus } from "./core/event-bus.js";
import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadExtensions, type ExtensionUIContext, type LoadedExtension } from "./core/extensions/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { resolvePromptInput } from "./core/system-prompt.js";
import { printTimings, time } from "./core/timings.js";
import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
import { ensureTool } from "./utils/tools-manager.js";
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
if (latestVersion && latestVersion !== currentVersion) {
return latestVersion;
}
return undefined;
} catch {
return undefined;
}
}
async function runInteractiveMode(
session: AgentSession,
version: string,
changelogMarkdown: string | undefined,
modelFallbackMessage: string | undefined,
modelsJsonError: string | undefined,
migratedProviders: string[],
versionCheckPromise: Promise<string | undefined>,
initialMessages: string[],
extensions: LoadedExtension[],
setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, extensions, setExtensionUIContext, fdPath);
await mode.init();
versionCheckPromise.then((newVersion) => {
if (newVersion) {
mode.showNewVersionNotification(newVersion);
}
});
mode.renderInitialMessages();
if (migratedProviders.length > 0) {
mode.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
}
if (modelsJsonError) {
mode.showError(`models.json error: ${modelsJsonError}`);
}
if (modelFallbackMessage) {
mode.showWarning(modelFallbackMessage);
}
if (initialMessage) {
try {
await session.prompt(initialMessage, { images: initialImages });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
mode.showError(errorMessage);
}
}
for (const message of initialMessages) {
try {
await session.prompt(message);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
mode.showError(errorMessage);
}
}
while (true) {
const userInput = await mode.getUserInput();
try {
await session.prompt(userInput);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
mode.showError(errorMessage);
}
}
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
): Promise<{
initialMessage?: string;
initialImages?: ImageContent[];
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
let initialMessage: string;
if (parsed.messages.length > 0) {
initialMessage = text + parsed.messages[0];
parsed.messages.shift();
} else {
initialMessage = text;
}
return {
initialMessage,
initialImages: images.length > 0 ? images : undefined,
};
}
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
if (parsed.continue || parsed.resume) {
return undefined;
}
const lastVersion = settingsManager.getLastChangelogVersion();
const changelogPath = getChangelogPath();
const entries = parseChangelog(changelogPath);
if (!lastVersion) {
if (entries.length > 0) {
settingsManager.setLastChangelogVersion(VERSION);
return entries.map((e) => e.content).join("\n\n");
}
} else {
const newEntries = getNewEntries(entries, lastVersion);
if (newEntries.length > 0) {
settingsManager.setLastChangelogVersion(VERSION);
return newEntries.map((e) => e.content).join("\n\n");
}
}
return undefined;
}
/**
* Resolve a session argument to a file path.
* If it looks like a path, use as-is. Otherwise try to match as session ID prefix.
*/
function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): string {
// If it looks like a file path, use as-is
if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
return sessionArg;
}
// Try to match as session ID (full or partial UUID)
const sessions = SessionManager.list(cwd, sessionDir);
const matches = sessions.filter((s) => s.id.startsWith(sessionArg));
if (matches.length >= 1) {
return matches[0].path; // Already sorted by modified time (most recent first)
}
// No match - return original (will create new session)
return sessionArg;
}
function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
if (parsed.noSession) {
return SessionManager.inMemory();
}
if (parsed.session) {
const resolvedPath = resolveSessionPath(parsed.session, cwd, parsed.sessionDir);
return SessionManager.open(resolvedPath, parsed.sessionDir);
}
if (parsed.continue) {
return SessionManager.continueRecent(cwd, parsed.sessionDir);
}
// --resume is handled separately (needs picker UI)
// If --session-dir provided without --continue/--resume, create new session there
if (parsed.sessionDir) {
return SessionManager.create(cwd, parsed.sessionDir);
}
// Default case (new session) returns undefined, SDK will create one
return undefined;
}
/** Discover SYSTEM.md file if no CLI system prompt was provided */
function discoverSystemPromptFile(): string | undefined {
// Check project-local first: .pi/SYSTEM.md
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
// Fall back to global: ~/.pi/agent/SYSTEM.md
const globalPath = join(getAgentDir(), "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
preloadedExtensions?: LoadedExtension[],
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
// Auto-discover SYSTEM.md if no CLI system prompt provided
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(parsed.appendSystemPrompt, "append system prompt");
if (sessionManager) {
options.sessionManager = sessionManager;
}
// Model from CLI
if (parsed.provider && parsed.model) {
const model = modelRegistry.find(parsed.provider, parsed.model);
if (!model) {
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
process.exit(1);
}
options.model = model;
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
options.model = scopedModels[0].model;
}
// Thinking level
if (parsed.thinking) {
options.thinkingLevel = parsed.thinking;
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
options.thinkingLevel = scopedModels[0].thinkingLevel;
}
// Scoped models for Ctrl+P cycling
if (scopedModels.length > 0) {
options.scopedModels = scopedModels;
}
// API key from CLI - set in authStorage
// (handled by caller before createAgentSession)
// System prompt
if (resolvedSystemPrompt && resolvedAppendPrompt) {
options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
} else if (resolvedSystemPrompt) {
options.systemPrompt = resolvedSystemPrompt;
} else if (resolvedAppendPrompt) {
options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
}
// Tools
if (parsed.tools) {
options.tools = parsed.tools.map((name) => allTools[name]);
}
// Skills
if (parsed.noSkills) {
options.skills = [];
}
// Pre-loaded extensions (from early CLI flag discovery)
if (preloadedExtensions && preloadedExtensions.length > 0) {
options.preloadedExtensions = preloadedExtensions;
}
return options;
}
export async function main(args: string[]) {
time("start");
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
// Create AuthStorage and ModelRegistry upfront
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
time("discoverModels");
// First pass: parse args to get --extension paths
const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load extensions to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const eventBus = createEventBus();
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
// Merge CLI --extension args with settings.json extensions
const extensionPaths = [...settingsManager.getExtensionPaths(), ...(firstPass.extensions ?? [])];
const { extensions: loadedExtensions } = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
time("discoverExtensionFlags");
// Collect all extension flags
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of loadedExtensions) {
for (const [name, flag] of ext.flags) {
extensionFlags.set(name, { type: flag.type });
}
}
// Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags);
time("parseArgs");
// Pass flag values to extensions
for (const [name, value] of parsed.unknownFlags) {
for (const ext of loadedExtensions) {
if (ext.flags.has(name)) {
ext.setFlagValue(name, value);
}
}
}
if (parsed.version) {
console.log(VERSION);
return;
}
if (parsed.help) {
printHelp();
return;
}
if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern);
return;
}
if (parsed.export) {
try {
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
const result = exportFromFile(parsed.export, outputPath);
console.log(`Exported to: ${result}`);
return;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to export session";
console.error(chalk.red(`Error: ${message}`));
process.exit(1);
}
}
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
process.exit(1);
}
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
initTheme(settingsManager.getTheme(), isInteractive);
time("initTheme");
// Show deprecation warnings in interactive mode
if (isInteractive && deprecationWarnings.length > 0) {
await showDeprecationWarnings(deprecationWarnings);
}
let scopedModels: ScopedModel[] = [];
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
if (modelPatterns && modelPatterns.length > 0) {
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
time("resolveModelScope");
}
// Create session manager based on CLI flags
let sessionManager = createSessionManager(parsed, cwd);
time("createSessionManager");
// Handle --resume: show session picker
if (parsed.resume) {
const sessions = SessionManager.list(cwd, parsed.sessionDir);
time("SessionManager.list");
if (sessions.length === 0) {
console.log(chalk.dim("No sessions found"));
return;
}
const selectedPath = await selectSession(sessions);
time("selectSession");
if (!selectedPath) {
console.log(chalk.dim("No session selected"));
return;
}
sessionManager = SessionManager.open(selectedPath);
}
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedExtensions);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.eventBus = eventBus;
// Handle CLI --api-key as runtime override (not persisted)
if (parsed.apiKey) {
if (!sessionOptions.model) {
console.error(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
process.exit(1);
}
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
}
time("buildSessionOptions");
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) {
console.error(chalk.red("No models available."));
console.error(chalk.yellow("\nSet an API key environment variable:"));
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
process.exit(1);
}
// Clamp thinking level to model capabilities (for CLI override case)
if (session.model && parsed.thinking) {
let effectiveThinking = parsed.thinking;
if (!session.model.reasoning) {
effectiveThinking = "off";
} else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) {
effectiveThinking = "high";
}
if (effectiveThinking !== session.thinkingLevel) {
session.setThinkingLevel(effectiveThinking);
}
}
if (mode === "rpc") {
await runRpcMode(session);
} else if (isInteractive) {
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
if (scopedModels.length > 0) {
const modelList = scopedModels
.map((sm) => {
const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : "";
return `${sm.model.id}${thinkingStr}`;
})
.join(", ");
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
}
const fdPath = await ensureTool("fd");
time("ensureTool(fd)");
printTimings();
await runInteractiveMode(
session,
VERSION,
changelogMarkdown,
modelFallbackMessage,
modelRegistry.getError(),
migratedProviders,
versionCheckPromise,
parsed.messages,
extensionsResult.extensions,
extensionsResult.setUIContext,
initialMessage,
initialImages,
fdPath,
);
} else {
await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages);
stopThemeWatcher();
if (process.stdout.writableLength > 0) {
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
}
process.exit(0);
}
}