Files
feynman/src/cli.ts
2026-04-15 13:46:12 -07:00

574 lines
17 KiB
TypeScript

import "dotenv/config";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
import {
getUserName as getAlphaUserName,
isLoggedIn as isAlphaLoggedIn,
login as loginAlpha,
logout as logoutAlpha,
} from "@companion-ai/alpha-hub/lib";
import { SettingsManager } from "@mariozechner/pi-coding-agent";
import { syncBundledAssets } from "./bootstrap/sync.js";
import { ensureFeynmanHome, getDefaultSessionDir, getFeynmanAgentDir, getFeynmanHome } from "./config/paths.js";
import { launchPiChat } from "./pi/launch.js";
import { installPackageSources, updateConfiguredPackages } from "./pi/package-ops.js";
import { MAX_NATIVE_PACKAGE_NODE_MAJOR } from "./pi/package-presets.js";
import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js";
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js";
import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js";
import {
authenticateModelProvider,
getCurrentModelSpec,
loginModelProvider,
logoutModelProvider,
printModelList,
setDefaultModelSpec,
} from "./model/commands.js";
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "./model/catalog.js";
import { clearSearchConfig, printSearchStatus, setSearchProvider } from "./search/commands.js";
import type { PiWebSearchProvider } from "./pi/web-access.js";
import { runDoctor, runStatus } from "./setup/doctor.js";
import { setupPreviewDependencies } from "./setup/preview.js";
import { runSetup } from "./setup/setup.js";
import { ASH, printAsciiHeader, printInfo, printPanel, printSection, RESET, SAGE } from "./ui/terminal.js";
import { createModelRegistry } from "./model/registry.js";
import {
cliCommandSections,
formatCliWorkflowUsage,
legacyFlags,
readPromptSpecs,
topLevelCommandNames,
} from "../metadata/commands.mjs";
const TOP_LEVEL_COMMANDS = new Set(topLevelCommandNames);
function printHelpLine(usage: string, description: string): void {
const width = 30;
const padding = Math.max(1, width - usage.length);
console.log(` ${SAGE}${usage}${RESET}${" ".repeat(padding)}${ASH}${description}${RESET}`);
}
function printHelp(appRoot: string): void {
const workflowCommands = readPromptSpecs(appRoot).filter(
(command) => command.section === "Research Workflows" && command.topLevelCli,
);
printAsciiHeader([
"Research-first agent shell built on Pi.",
"Use `feynman setup` first if this is a new machine.",
]);
printSection("Getting Started");
printInfo("feynman");
printInfo("feynman setup");
printInfo("feynman doctor");
printInfo("feynman model");
printInfo("feynman search status");
printSection("Commands");
for (const section of cliCommandSections) {
for (const command of section.commands) {
printHelpLine(command.usage, command.description);
}
}
printSection("Research Workflows");
for (const command of workflowCommands) {
printHelpLine(formatCliWorkflowUsage(command), command.description);
}
printSection("Legacy Flags");
for (const flag of legacyFlags) {
printHelpLine(flag.usage, flag.description);
}
printSection("REPL");
printInfo("Inside the REPL, slash workflows come from the live prompt-template and extension command set.");
}
async function handleAlphaCommand(action: string | undefined): Promise<void> {
if (action === "login") {
const result = await loginAlpha();
const name =
result.userInfo &&
typeof result.userInfo === "object" &&
"name" in result.userInfo &&
typeof result.userInfo.name === "string"
? result.userInfo.name
: getAlphaUserName();
console.log(name ? `alphaXiv login complete: ${name}` : "alphaXiv login complete");
return;
}
if (action === "logout") {
logoutAlpha();
console.log("alphaXiv auth cleared");
return;
}
if (!action || action === "status") {
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
console.log(name ? `alphaXiv logged in as ${name}` : "alphaXiv logged in");
} else {
console.log("alphaXiv not logged in");
}
return;
}
throw new Error(`Unknown alpha command: ${action}`);
}
async function handleModelCommand(subcommand: string | undefined, args: string[], feynmanSettingsPath: string, feynmanAuthPath: string): Promise<void> {
if (!subcommand || subcommand === "list") {
printModelList(feynmanSettingsPath, feynmanAuthPath);
return;
}
if (subcommand === "login") {
if (args[0]) {
// Specific provider given - resolve OAuth vs API-key setup automatically
await loginModelProvider(feynmanAuthPath, args[0], feynmanSettingsPath);
} else {
// No provider specified - show auth method choice
await authenticateModelProvider(feynmanAuthPath, feynmanSettingsPath);
}
return;
}
if (subcommand === "logout") {
await logoutModelProvider(feynmanAuthPath, args[0]);
return;
}
if (subcommand === "set") {
const spec = args[0];
if (!spec) {
throw new Error("Usage: feynman model set <provider/model|provider:model>");
}
setDefaultModelSpec(feynmanSettingsPath, feynmanAuthPath, spec);
return;
}
if (subcommand === "tier") {
const requested = args[0];
if (!requested) {
console.log(getConfiguredServiceTier(feynmanSettingsPath) ?? "not set");
return;
}
if (requested === "unset" || requested === "clear" || requested === "off") {
setConfiguredServiceTier(feynmanSettingsPath, undefined);
console.log("Cleared service tier override");
return;
}
const tier = normalizeServiceTier(requested);
if (!tier) {
throw new Error("Usage: feynman model tier <auto|default|flex|priority|standard_only|unset>");
}
setConfiguredServiceTier(feynmanSettingsPath, tier);
console.log(`Service tier set to ${tier}`);
return;
}
throw new Error(`Unknown model command: ${subcommand}`);
}
async function handleUpdateCommand(workingDir: string, feynmanAgentDir: string, source?: string): Promise<void> {
try {
const result = await updateConfiguredPackages(workingDir, feynmanAgentDir, source);
if (result.updated.length === 0) {
console.log("All packages up to date.");
return;
}
for (const updatedSource of result.updated) {
console.log(`Updated ${updatedSource}`);
}
for (const skippedSource of result.skipped) {
console.log(`Skipped ${skippedSource} on Node ${process.versions.node} (native packages are only supported through Node ${MAX_NATIVE_PACKAGE_NODE_MAJOR}.x).`);
}
console.log("All packages up to date.");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No supported package manager found")) {
console.log("No package manager is available for live package updates.");
console.log("If you installed the standalone app, rerun the installer to get newer bundled packages.");
return;
}
throw error;
}
}
async function handlePackagesCommand(subcommand: string | undefined, args: string[], workingDir: string, feynmanAgentDir: string): Promise<void> {
applyFeynmanPackageManagerEnv(feynmanAgentDir);
const settingsManager = SettingsManager.create(workingDir, feynmanAgentDir);
const configuredSources = new Set(
settingsManager
.getPackages()
.map((entry) => (typeof entry === "string" ? entry : entry.source))
.filter((entry): entry is string => typeof entry === "string"),
);
if (!subcommand || subcommand === "list") {
printPanel("Feynman Packages", [
"Core packages are installed by default to keep first-run setup fast.",
]);
printSection("Core");
for (const source of CORE_PACKAGE_SOURCES) {
printInfo(source);
}
printSection("Optional");
for (const preset of listOptionalPackagePresets()) {
const installed = preset.sources.every((source) => configuredSources.has(source));
printInfo(`${preset.name}${installed ? " (installed)" : ""} ${preset.description}`);
}
printInfo("Install with: feynman packages install <preset>");
return;
}
if (subcommand !== "install") {
throw new Error(`Unknown packages command: ${subcommand}`);
}
const target = args[0];
if (!target) {
throw new Error("Usage: feynman packages install <generative-ui|memory|session-search|all-extras>");
}
const sources = getOptionalPackagePresetSources(target);
if (!sources) {
throw new Error(`Unknown package preset: ${target}`);
}
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const isStandaloneBundle = !existsSync(resolve(appRoot, ".feynman", "runtime-workspace.tgz")) && existsSync(resolve(appRoot, ".feynman", "npm"));
if (target === "generative-ui" && process.platform === "darwin" && isStandaloneBundle) {
console.log("The generative-ui preset is currently unavailable in the standalone macOS bundle.");
console.log("Its native glimpseui dependency fails to compile reliably in that environment.");
console.log("If you need generative-ui, install Feynman through npm instead of the standalone bundle.");
return;
}
const pendingSources = sources.filter((source) => !configuredSources.has(source));
for (const source of sources) {
if (configuredSources.has(source)) {
console.log(`${source} already installed`);
}
}
if (pendingSources.length === 0) {
console.log("Optional packages installed.");
return;
}
try {
const result = await installPackageSources(workingDir, feynmanAgentDir, pendingSources, { persist: true });
for (const skippedSource of result.skipped) {
console.log(`Skipped ${skippedSource} on Node ${process.versions.node} (native packages are only supported through Node ${MAX_NATIVE_PACKAGE_NODE_MAJOR}.x).`);
}
await settingsManager.flush();
console.log("Optional packages installed.");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No supported package manager found")) {
console.log("No package manager is available for optional package installs.");
console.log("Install npm, pnpm, or bun, or rerun the standalone installer for bundled package updates.");
return;
}
throw error;
}
}
function handleSearchCommand(subcommand: string | undefined, args: string[]): void {
if (!subcommand || subcommand === "status") {
printSearchStatus();
return;
}
if (subcommand === "set") {
const provider = args[0] as PiWebSearchProvider | undefined;
const validProviders: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"];
if (!provider || !validProviders.includes(provider)) {
throw new Error("Usage: feynman search set <auto|perplexity|exa|gemini> [api-key]");
}
setSearchProvider(provider, args[1]);
return;
}
if (subcommand === "clear") {
clearSearchConfig();
return;
}
throw new Error(`Unknown search command: ${subcommand}`);
}
function loadPackageVersion(appRoot: string): { version?: string } {
try {
return JSON.parse(readFileSync(resolve(appRoot, "package.json"), "utf8")) as { version?: string };
} catch {
return {};
}
}
export function resolveInitialPrompt(
command: string | undefined,
rest: string[],
oneShotPrompt: string | undefined,
workflowCommands: Set<string>,
): string | undefined {
if (oneShotPrompt) {
return oneShotPrompt;
}
if (!command) {
return undefined;
}
if (command === "chat") {
return rest.length > 0 ? rest.join(" ") : undefined;
}
if (workflowCommands.has(command)) {
return [`/${command}`, ...rest].join(" ").trim();
}
if (!TOP_LEVEL_COMMANDS.has(command)) {
return [command, ...rest].join(" ");
}
return undefined;
}
export function shouldRunInteractiveSetup(
explicitModelSpec: string | undefined,
currentModelSpec: string | undefined,
isInteractiveTerminal: boolean,
authPath: string,
): boolean {
if (explicitModelSpec || !isInteractiveTerminal) {
return false;
}
const status = buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(authPath),
getAvailableModelRecords(authPath),
currentModelSpec,
);
return !status.currentValid;
}
export async function main(): Promise<void> {
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const feynmanVersion = loadPackageVersion(appRoot).version;
const bundledSettingsPath = resolve(appRoot, ".feynman", "settings.json");
const feynmanHome = getFeynmanHome();
const feynmanAgentDir = getFeynmanAgentDir(feynmanHome);
ensureFeynmanHome(feynmanHome);
syncBundledAssets(appRoot, feynmanAgentDir);
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: {
cwd: { type: "string" },
doctor: { type: "boolean" },
help: { type: "boolean" },
version: { type: "boolean" },
"alpha-login": { type: "boolean" },
"alpha-logout": { type: "boolean" },
"alpha-status": { type: "boolean" },
mode: { type: "string" },
model: { type: "string" },
"new-session": { type: "boolean" },
prompt: { type: "string" },
"service-tier": { type: "string" },
"session-dir": { type: "string" },
"setup-preview": { type: "boolean" },
thinking: { type: "string" },
},
});
if (values.help) {
printHelp(appRoot);
return;
}
if (values.version) {
if (feynmanVersion) {
console.log(feynmanVersion);
return;
}
throw new Error("Unable to determine the installed Feynman version.");
}
const workingDir = resolve(values.cwd ?? process.cwd());
const sessionDir = resolve(values["session-dir"] ?? getDefaultSessionDir(feynmanHome));
const feynmanSettingsPath = resolve(feynmanAgentDir, "settings.json");
const feynmanAuthPath = resolve(feynmanAgentDir, "auth.json");
const thinkingLevel = normalizeThinkingLevel(values.thinking ?? process.env.FEYNMAN_THINKING) ?? "medium";
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
if (values.doctor) {
runDoctor({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (values["setup-preview"]) {
const result = setupPreviewDependencies();
console.log(result.message);
return;
}
if (values["alpha-login"]) {
await handleAlphaCommand("login");
return;
}
if (values["alpha-logout"]) {
await handleAlphaCommand("logout");
return;
}
if (values["alpha-status"]) {
await handleAlphaCommand("status");
return;
}
const [command, ...rest] = positionals;
if (command === "help") {
printHelp(appRoot);
return;
}
if (command === "setup") {
await runSetup({
settingsPath: feynmanSettingsPath,
bundledSettingsPath,
authPath: feynmanAuthPath,
workingDir,
sessionDir,
appRoot,
defaultThinkingLevel: thinkingLevel,
});
return;
}
if (command === "doctor") {
runDoctor({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (command === "status") {
runStatus({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (command === "model") {
await handleModelCommand(rest[0], rest.slice(1), feynmanSettingsPath, feynmanAuthPath);
return;
}
if (command === "search") {
handleSearchCommand(rest[0], rest.slice(1));
return;
}
if (command === "packages") {
await handlePackagesCommand(rest[0], rest.slice(1), workingDir, feynmanAgentDir);
return;
}
if (command === "update") {
await handleUpdateCommand(workingDir, feynmanAgentDir, rest[0]);
return;
}
if (command === "alpha") {
await handleAlphaCommand(rest[0]);
return;
}
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
const explicitServiceTier = normalizeServiceTier(values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER);
const mode = values.mode;
if (mode !== undefined && mode !== "text" && mode !== "json" && mode !== "rpc") {
throw new Error("Unknown mode. Use text, json, or rpc.");
}
if ((values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER) && !explicitServiceTier) {
throw new Error("Unknown service tier. Use auto, default, flex, priority, or standard_only.");
}
if (explicitServiceTier) {
process.env.FEYNMAN_SERVICE_TIER = explicitServiceTier;
}
if (explicitModelSpec) {
const modelRegistry = createModelRegistry(feynmanAuthPath);
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
if (!explicitModel) {
throw new Error(`Unknown model: ${explicitModelSpec}`);
}
}
const currentModelSpec = getCurrentModelSpec(feynmanSettingsPath);
if (shouldRunInteractiveSetup(
explicitModelSpec,
currentModelSpec,
Boolean(process.stdin.isTTY && process.stdout.isTTY),
feynmanAuthPath,
)) {
await runSetup({
settingsPath: feynmanSettingsPath,
bundledSettingsPath,
authPath: feynmanAuthPath,
workingDir,
sessionDir,
appRoot,
defaultThinkingLevel: thinkingLevel,
});
if (!getCurrentModelSpec(feynmanSettingsPath)) {
return;
}
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
}
await launchPiChat({
appRoot,
workingDir,
sessionDir,
feynmanAgentDir,
feynmanVersion,
mode,
thinkingLevel,
explicitModelSpec,
oneShotPrompt: values.prompt,
initialPrompt: resolveInitialPrompt(command, rest, values.prompt, new Set(readPromptSpecs(appRoot).filter((s) => s.topLevelCli).map((s) => s.name))),
});
}