Fix Feynman onboarding and local install reliability

This commit is contained in:
Advait Paliwal
2026-04-15 13:46:12 -07:00
parent fa259f5cea
commit dd3c07633b
11 changed files with 512 additions and 123 deletions

View File

@@ -1,6 +1,6 @@
import "dotenv/config";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
@@ -11,11 +11,13 @@ import {
login as loginAlpha,
logout as logoutAlpha,
} from "@companion-ai/alpha-hub/lib";
import { DefaultPackageManager, SettingsManager } from "@mariozechner/pi-coding-agent";
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";
@@ -28,6 +30,7 @@ import {
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";
@@ -180,27 +183,30 @@ async function handleModelCommand(subcommand: string | undefined, args: string[]
}
async function handleUpdateCommand(workingDir: string, feynmanAgentDir: string, source?: string): Promise<void> {
applyFeynmanPackageManagerEnv(feynmanAgentDir);
const settingsManager = SettingsManager.create(workingDir, feynmanAgentDir);
const packageManager = new DefaultPackageManager({
cwd: workingDir,
agentDir: feynmanAgentDir,
settingsManager,
});
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
console.log(`Updating ${event.source}...`);
} else if (event.type === "complete") {
console.log(`Updated ${event.source}`);
} else if (event.type === "error") {
console.error(`Failed to update ${event.source}: ${event.message ?? "unknown error"}`);
try {
const result = await updateConfiguredPackages(workingDir, feynmanAgentDir, source);
if (result.updated.length === 0) {
console.log("All packages up to date.");
return;
}
});
await packageManager.update(source);
await settingsManager.flush();
console.log("All packages up to date.");
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> {
@@ -244,30 +250,44 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin
throw new Error(`Unknown package preset: ${target}`);
}
const packageManager = new DefaultPackageManager({
cwd: workingDir,
agentDir: feynmanAgentDir,
settingsManager,
});
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
console.log(`Installing ${event.source}...`);
} else if (event.type === "complete") {
console.log(`Installed ${event.source}`);
} else if (event.type === "error") {
console.error(`Failed to install ${event.source}: ${event.message ?? "unknown error"}`);
}
});
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`);
continue;
}
await packageManager.install(source);
}
await settingsManager.flush();
console.log("Optional packages 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 {
@@ -326,6 +346,24 @@ export function resolveInitialPrompt(
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, "..");
@@ -498,7 +536,13 @@ export async function main(): Promise<void> {
}
}
if (!explicitModelSpec && !getCurrentModelSpec(feynmanSettingsPath) && process.stdin.isTTY && process.stdout.isTTY) {
const currentModelSpec = getCurrentModelSpec(feynmanSettingsPath);
if (shouldRunInteractiveSetup(
explicitModelSpec,
currentModelSpec,
Boolean(process.stdin.isTTY && process.stdout.isTTY),
feynmanAuthPath,
)) {
await runSetup({
settingsPath: feynmanSettingsPath,
bundledSettingsPath,

View File

@@ -4,7 +4,7 @@ import { exec as execCallback } from "node:child_process";
import { promisify } from "node:util";
import { readJson } from "../pi/settings.js";
import { promptChoice, promptText } from "../setup/prompts.js";
import { promptChoice, promptSelect, promptText, type PromptSelectOption } from "../setup/prompts.js";
import { openUrl } from "../system/open-url.js";
import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js";
import {
@@ -55,13 +55,22 @@ async function selectOAuthProvider(authPath: string, action: "login" | "logout")
return providers[0];
}
const choices = providers.map((provider) => `${provider.id}${provider.name ?? provider.id}`);
choices.push("Cancel");
const selection = await promptChoice(`Choose an OAuth provider to ${action}:`, choices, 0);
if (selection >= providers.length) {
const selection = await promptSelect<OAuthProviderInfo | "cancel">(
`Choose an OAuth provider to ${action}:`,
[
...providers.map((provider) => ({
value: provider,
label: provider.name ?? provider.id,
hint: provider.id,
})),
{ value: "cancel", label: "Cancel" },
],
providers[0],
);
if (selection === "cancel") {
return undefined;
}
return providers[selection];
return selection;
}
type ApiKeyProviderInfo = {
@@ -71,10 +80,10 @@ type ApiKeyProviderInfo = {
};
const API_KEY_PROVIDERS: ApiKeyProviderInfo[] = [
{ id: "__custom__", label: "Custom provider (baseUrl + API key)" },
{ id: "openai", label: "OpenAI Platform API", envVar: "OPENAI_API_KEY" },
{ id: "anthropic", label: "Anthropic API", envVar: "ANTHROPIC_API_KEY" },
{ id: "google", label: "Google Gemini API", envVar: "GEMINI_API_KEY" },
{ id: "__custom__", label: "Custom provider (local/self-hosted/proxy)" },
{ id: "amazon-bedrock", label: "Amazon Bedrock (AWS credential chain)" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
{ id: "zai", label: "Z.AI / GLM", envVar: "ZAI_API_KEY" },
@@ -118,15 +127,21 @@ export function resolveModelProviderForCommand(
}
async function selectApiKeyProvider(): Promise<ApiKeyProviderInfo | undefined> {
const choices = API_KEY_PROVIDERS.map(
(provider) => `${provider.id}${provider.label}${provider.envVar ? ` (${provider.envVar})` : ""}`,
);
choices.push("Cancel");
const selection = await promptChoice("Choose an API-key provider:", choices, 0);
if (selection >= API_KEY_PROVIDERS.length) {
const options: PromptSelectOption<ApiKeyProviderInfo | "cancel">[] = API_KEY_PROVIDERS.map((provider) => ({
value: provider,
label: provider.label,
hint: provider.id === "__custom__"
? "Ollama, vLLM, LM Studio, proxies"
: provider.envVar ?? provider.id,
}));
options.push({ value: "cancel", label: "Cancel" });
const defaultProvider = API_KEY_PROVIDERS.find((provider) => provider.id === "openai") ?? API_KEY_PROVIDERS[0];
const selection = await promptSelect("Choose an API-key provider:", options, defaultProvider);
if (selection === "cancel") {
return undefined;
}
return API_KEY_PROVIDERS[selection];
return selection;
}
type CustomProviderSetup = {
@@ -656,13 +671,17 @@ export function printModelList(settingsPath: string, authPath: string): void {
export async function authenticateModelProvider(authPath: string, settingsPath?: string): Promise<boolean> {
const choices = [
"API key (OpenAI, Anthropic, Google, custom provider, ...)",
"OAuth login (ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)",
"OAuth login (recommended: ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)",
"API key or custom provider (OpenAI, Anthropic, Google, local/self-hosted, ...)",
"Cancel",
];
const selection = await promptChoice("How do you want to authenticate?", choices, 0);
if (selection === 0) {
return loginModelProvider(authPath, undefined, settingsPath);
}
if (selection === 1) {
const configured = await configureApiKeyProvider(authPath);
if (configured) {
maybeSetRecommendedDefaultModel(settingsPath, authPath);
@@ -670,10 +689,6 @@ export async function authenticateModelProvider(authPath: string, settingsPath?:
return configured;
}
if (selection === 1) {
return loginModelProvider(authPath, undefined, settingsPath);
}
printInfo("Authentication cancelled.");
return false;
}
@@ -788,20 +803,20 @@ export async function runModelSetup(settingsPath: string, authPath: string): Pro
while (status.availableModels.length === 0) {
const choices = [
"API key (OpenAI, Anthropic, ZAI, Kimi, MiniMax, ...)",
"OAuth login (ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)",
"OAuth login (recommended: ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)",
"API key or custom provider (OpenAI, Anthropic, ZAI, Kimi, MiniMax, ...)",
"Cancel",
];
const selection = await promptChoice("Choose how to configure model access:", choices, 0);
if (selection === 0) {
const configured = await configureApiKeyProvider(authPath);
if (!configured) {
const loggedIn = await loginModelProvider(authPath, undefined, settingsPath);
if (!loggedIn) {
status = collectModelStatus(settingsPath, authPath);
continue;
}
} else if (selection === 1) {
const loggedIn = await loginModelProvider(authPath, undefined, settingsPath);
if (!loggedIn) {
const configured = await configureApiKeyProvider(authPath);
if (!configured) {
status = collectModelStatus(settingsPath, authPath);
continue;
}

View File

@@ -1,30 +1,130 @@
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
confirm as clackConfirm,
intro as clackIntro,
isCancel,
multiselect as clackMultiselect,
outro as clackOutro,
select as clackSelect,
text as clackText,
type Option,
} from "@clack/prompts";
export async function promptText(question: string, defaultValue = ""): Promise<string> {
if (!input.isTTY || !output.isTTY) {
export class SetupCancelledError extends Error {
constructor(message = "setup cancelled") {
super(message);
this.name = "SetupCancelledError";
}
}
export type PromptSelectOption<T = string> = {
value: T;
label: string;
hint?: string;
};
function ensureInteractiveTerminal(): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error("feynman setup requires an interactive terminal.");
}
const rl = createInterface({ input, output });
try {
const suffix = defaultValue ? ` [${defaultValue}]` : "";
const value = (await rl.question(`${question}${suffix}: `)).trim();
return value || defaultValue;
} finally {
rl.close();
}
function guardCancelled<T>(value: T | symbol): T {
if (isCancel(value)) {
throw new SetupCancelledError();
}
return value;
}
export function isInteractiveTerminal(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export async function promptIntro(title: string): Promise<void> {
ensureInteractiveTerminal();
clackIntro(title);
}
export async function promptOutro(message: string): Promise<void> {
ensureInteractiveTerminal();
clackOutro(message);
}
export async function promptText(question: string, defaultValue = "", placeholder?: string): Promise<string> {
ensureInteractiveTerminal();
const value = guardCancelled(
await clackText({
message: question,
initialValue: defaultValue || undefined,
placeholder: placeholder ?? (defaultValue || undefined),
}),
);
const normalized = String(value ?? "").trim();
return normalized || defaultValue;
}
export async function promptSelect<T>(
question: string,
options: PromptSelectOption<T>[],
initialValue?: T,
): Promise<T> {
ensureInteractiveTerminal();
const selection = guardCancelled(
await clackSelect({
message: question,
options: options.map((option) => ({
value: option.value,
label: option.label,
hint: option.hint,
})) as Option<T>[],
initialValue,
}),
);
return selection;
}
export async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
console.log(question);
for (const [index, choice] of choices.entries()) {
const marker = index === defaultIndex ? "*" : " ";
console.log(` ${marker} ${index + 1}. ${choice}`);
}
const answer = await promptText("Select", String(defaultIndex + 1));
const parsed = Number(answer);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) {
return defaultIndex;
}
return parsed - 1;
const options = choices.map((choice, index) => ({
value: index,
label: choice,
}));
return promptSelect(question, options, Math.max(0, Math.min(defaultIndex, choices.length - 1)));
}
export async function promptConfirm(question: string, initialValue = true): Promise<boolean> {
ensureInteractiveTerminal();
return guardCancelled(
await clackConfirm({
message: question,
initialValue,
}),
);
}
export async function promptMultiSelect<T>(
question: string,
options: PromptSelectOption<T>[],
initialValues: T[] = [],
): Promise<T[]> {
ensureInteractiveTerminal();
const selection = guardCancelled(
await clackMultiselect({
message: question,
options: options.map((option) => ({
value: option.value,
label: option.label,
hint: option.hint,
})) as Option<T>[],
initialValues,
required: false,
}),
);
return selection;
}

View File

@@ -1,4 +1,6 @@
export const MIN_NODE_VERSION = "20.19.0";
export const MAX_NODE_MAJOR = 24;
export const PREFERRED_NODE_MAJOR = 22;
type ParsedNodeVersion = {
major: number;
@@ -22,16 +24,21 @@ function compareNodeVersions(left: ParsedNodeVersion, right: ParsedNodeVersion):
}
export function isSupportedNodeVersion(version = process.versions.node): boolean {
return compareNodeVersions(parseNodeVersion(version), parseNodeVersion(MIN_NODE_VERSION)) >= 0;
const parsed = parseNodeVersion(version);
return compareNodeVersions(parsed, parseNodeVersion(MIN_NODE_VERSION)) >= 0 && parsed.major <= MAX_NODE_MAJOR;
}
export function getUnsupportedNodeVersionLines(version = process.versions.node): string[] {
const isWindows = process.platform === "win32";
const parsed = parseNodeVersion(version);
const rangeText = `Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x`;
return [
`feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${version}).`,
isWindows
? "Install a newer Node.js from https://nodejs.org, or use the standalone installer:"
: "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:",
`feynman supports ${rangeText} (detected ${version}).`,
parsed.major > MAX_NODE_MAJOR
? "This newer Node release is not supported yet because native Pi packages may fail to build."
: isWindows
? "Install a supported Node.js release from https://nodejs.org, or use the standalone installer:"
: `Switch to a supported Node release with \`nvm install ${PREFERRED_NODE_MAJOR} && nvm use ${PREFERRED_NODE_MAJOR}\`, or use the standalone installer:`,
isWindows
? "irm https://feynman.is/install.ps1 | iex"
: "curl -fsSL https://feynman.is/install | bash",