diff --git a/README.md b/README.md index 6079feb..42513fd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ curl -fsSL https://feynman.is/install | bash irm https://feynman.is/install.ps1 | iex ``` -The one-line installer fetches the latest tagged release. To pin a version, pass it explicitly, for example `curl -fsSL https://feynman.is/install | bash -s -- 0.2.20`. +The one-line installer fetches the latest tagged release. To pin a version, pass it explicitly, for example `curl -fsSL https://feynman.is/install | bash -s -- 0.2.21`. The installer downloads a standalone native bundle with its own Node.js runtime. diff --git a/package-lock.json b/package-lock.json index 24921b5..8b000da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@companion-ai/feynman", - "version": "0.2.20", + "version": "0.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@companion-ai/feynman", - "version": "0.2.20", + "version": "0.2.21", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7bc820c..9b4278c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@companion-ai/feynman", - "version": "0.2.20", + "version": "0.2.21", "description": "Research-first CLI agent built on Pi and alphaXiv", "license": "MIT", "type": "module", diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index dd0c095..1e9a50a 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -110,7 +110,7 @@ This usually means the release exists, but not all platform bundles were uploade Workarounds: - try again after the release finishes publishing - pass the latest published version explicitly, e.g.: - & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 + & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.21 "@ } diff --git a/scripts/install/install.sh b/scripts/install/install.sh index d7d80d4..657a11c 100644 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -261,7 +261,7 @@ This usually means the release exists, but not all platform bundles were uploade Workarounds: - try again after the release finishes publishing - pass the latest published version explicitly, e.g.: - curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 + curl -fsSL https://feynman.is/install | bash -s -- 0.2.21 EOF exit 1 fi diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index 6e263ce..7aae03d 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -260,6 +260,23 @@ function ensureParentDir(path) { mkdirSync(dirname(path), { recursive: true }); } +function packageDependencyExists(packagePath, globalNodeModulesRoot, dependency) { + return existsSync(resolve(packagePath, "node_modules", dependency)) || + existsSync(resolve(globalNodeModulesRoot, dependency)); +} + +function installedPackageLooksUsable(packagePath, globalNodeModulesRoot) { + if (!existsSync(resolve(packagePath, "package.json"))) return false; + try { + const pkg = JSON.parse(readFileSync(resolve(packagePath, "package.json"), "utf8")); + return Object.keys(pkg.dependencies ?? {}).every((dependency) => + packageDependencyExists(packagePath, globalNodeModulesRoot, dependency) + ); + } catch { + return false; + } +} + function linkPointsTo(linkPath, targetPath) { try { if (!lstatSync(linkPath).isSymbolicLink()) return false; @@ -281,6 +298,8 @@ function ensureBundledPackageLinks(packageSpecs) { try { if (lstatSync(targetPath).isSymbolicLink()) { rmSync(targetPath, { force: true }); + } else if (!installedPackageLooksUsable(targetPath, globalNodeModulesRoot)) { + rmSync(targetPath, { recursive: true, force: true }); } } catch {} if (existsSync(targetPath)) continue; diff --git a/src/model/registry.ts b/src/model/registry.ts index 65ef860..e20b371 100644 --- a/src/model/registry.ts +++ b/src/model/registry.ts @@ -1,11 +1,41 @@ import { dirname, resolve } from "node:path"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { getModels } from "@mariozechner/pi-ai"; +import { anthropicOAuthProvider } from "@mariozechner/pi-ai/oauth"; export function getModelsJsonPath(authPath: string): string { return resolve(dirname(authPath), "models.json"); } -export function createModelRegistry(authPath: string): ModelRegistry { - return ModelRegistry.create(AuthStorage.create(authPath), getModelsJsonPath(authPath)); +function registerFeynmanModelOverlays(modelRegistry: ModelRegistry): void { + const anthropicModels = getModels("anthropic"); + if (anthropicModels.some((model) => model.id === "claude-opus-4-7")) { + return; + } + + const opus46 = anthropicModels.find((model) => model.id === "claude-opus-4-6"); + if (!opus46) { + return; + } + + modelRegistry.registerProvider("anthropic", { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + oauth: anthropicOAuthProvider, + models: [ + ...anthropicModels, + { + ...opus46, + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + }, + ], + }); +} + +export function createModelRegistry(authPath: string): ModelRegistry { + const registry = ModelRegistry.create(AuthStorage.create(authPath), getModelsJsonPath(authPath)); + registerFeynmanModelOverlays(registry); + return registry; } diff --git a/src/pi/package-ops.ts b/src/pi/package-ops.ts index cf868b7..6494c6a 100644 --- a/src/pi/package-ops.ts +++ b/src/pi/package-ops.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; @@ -423,6 +423,47 @@ function linkDirectory(linkPath: string, targetPath: string): void { } } +function packageNameToPath(root: string, packageName: string): string { + return resolve(root, packageName); +} + +function packageDependencyExists(packagePath: string, globalNodeModulesRoot: string, dependency: string): boolean { + return existsSync(packageNameToPath(resolve(packagePath, "node_modules"), dependency)) || + existsSync(packageNameToPath(globalNodeModulesRoot, dependency)); +} + +function installedPackageLooksUsable(packagePath: string, globalNodeModulesRoot: string): boolean { + if (!existsSync(resolve(packagePath, "package.json"))) { + return false; + } + + try { + const pkg = JSON.parse(readFileSync(resolve(packagePath, "package.json"), "utf8")) as { + dependencies?: Record; + }; + const dependencies = Object.keys(pkg.dependencies ?? {}); + return dependencies.every((dependency) => packageDependencyExists(packagePath, globalNodeModulesRoot, dependency)); + } catch { + return false; + } +} + +function replaceBrokenPackageWithBundledCopy(targetPath: string, bundledPackagePath: string, globalNodeModulesRoot: string): boolean { + if (!existsSync(targetPath)) { + return false; + } + if (pathsMatchSymlinkTarget(targetPath, bundledPackagePath)) { + return false; + } + if (installedPackageLooksUsable(targetPath, globalNodeModulesRoot)) { + return false; + } + + rmSync(targetPath, { recursive: true, force: true }); + linkDirectory(targetPath, bundledPackagePath); + return true; +} + export function seedBundledWorkspacePackages( agentDir: string, appRoot: string, @@ -446,6 +487,10 @@ export function seedBundledWorkspacePackages( if (!existsSync(bundledPackagePath)) continue; const targetPath = resolve(globalNodeModulesRoot, parsed.name); + if (replaceBrokenPackageWithBundledCopy(targetPath, bundledPackagePath, globalNodeModulesRoot)) { + seeded.push(source); + continue; + } if (!existsSync(targetPath)) { linkDirectory(targetPath, bundledPackagePath); seeded.push(source); diff --git a/tests/model-harness.test.ts b/tests/model-harness.test.ts index 4319185..3e8ea38 100644 --- a/tests/model-harness.test.ts +++ b/tests/model-harness.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { resolveInitialPrompt, shouldRunInteractiveSetup } from "../src/cli.js"; import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js"; import { resolveModelProviderForCommand, setDefaultModelSpec } from "../src/model/commands.js"; +import { createModelRegistry } from "../src/model/registry.js"; function createAuthPath(contents: Record): string { const root = mkdtempSync(join(tmpdir(), "feynman-auth-")); @@ -26,6 +27,17 @@ test("chooseRecommendedModel prefers the strongest authenticated research model" assert.equal(recommendation?.spec, "anthropic/claude-opus-4-6"); }); +test("createModelRegistry overlays new Anthropic Opus model before upstream Pi updates", () => { + const authPath = createAuthPath({ + anthropic: { type: "api_key", key: "anthropic-test-key" }, + }); + + const registry = createModelRegistry(authPath); + + assert.ok(registry.find("anthropic", "claude-opus-4-7")); + assert.equal(registry.getAvailable().some((model) => model.provider === "anthropic" && model.id === "claude-opus-4-7"), true); +}); + test("setDefaultModelSpec accepts a unique bare model id from authenticated models", () => { const authPath = createAuthPath({ openai: { type: "api_key", key: "openai-test-key" }, diff --git a/tests/package-ops.test.ts b/tests/package-ops.test.ts index 16a56c5..3d815a1 100644 --- a/tests/package-ops.test.ts +++ b/tests/package-ops.test.ts @@ -6,13 +6,17 @@ import { join, resolve } from "node:path"; import { installPackageSources, seedBundledWorkspacePackages, updateConfiguredPackages } from "../src/pi/package-ops.js"; -function createBundledWorkspace(appRoot: string, packageNames: string[]): void { +function createBundledWorkspace( + appRoot: string, + packageNames: string[], + dependenciesByPackage: Record> = {}, +): void { for (const packageName of packageNames) { const packageDir = resolve(appRoot, ".feynman", "npm", "node_modules", packageName); mkdirSync(packageDir, { recursive: true }); writeFileSync( join(packageDir, "package.json"), - JSON.stringify({ name: packageName, version: "1.0.0" }, null, 2) + "\n", + JSON.stringify({ name: packageName, version: "1.0.0", dependencies: dependenciesByPackage[packageName] }, null, 2) + "\n", "utf8", ); } @@ -76,6 +80,33 @@ test("seedBundledWorkspacePackages preserves existing installed packages", () => assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), false); }); +test("seedBundledWorkspacePackages repairs broken existing bundled packages", () => { + const appRoot = mkdtempSync(join(tmpdir(), "feynman-bundle-")); + const homeRoot = mkdtempSync(join(tmpdir(), "feynman-home-")); + const agentDir = resolve(homeRoot, "agent"); + const existingPackageDir = resolve(homeRoot, "npm-global", "lib", "node_modules", "pi-markdown-preview"); + + mkdirSync(agentDir, { recursive: true }); + createBundledWorkspace(appRoot, ["pi-markdown-preview", "puppeteer-core"], { + "pi-markdown-preview": { "puppeteer-core": "^24.0.0" }, + }); + mkdirSync(existingPackageDir, { recursive: true }); + writeFileSync( + resolve(existingPackageDir, "package.json"), + JSON.stringify({ name: "pi-markdown-preview", version: "broken", dependencies: { "puppeteer-core": "^24.0.0" } }) + "\n", + "utf8", + ); + + const seeded = seedBundledWorkspacePackages(agentDir, appRoot, ["npm:pi-markdown-preview"]); + + assert.deepEqual(seeded, ["npm:pi-markdown-preview"]); + assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), true); + assert.equal( + readFileSync(resolve(existingPackageDir, "package.json"), "utf8").includes('"version": "1.0.0"'), + true, + ); +}); + test("installPackageSources filters noisy npm chatter but preserves meaningful output", async () => { const root = mkdtempSync(join(tmpdir(), "feynman-package-ops-")); const workingDir = resolve(root, "project"); diff --git a/website/public/install b/website/public/install index d7d80d4..657a11c 100644 --- a/website/public/install +++ b/website/public/install @@ -261,7 +261,7 @@ This usually means the release exists, but not all platform bundles were uploade Workarounds: - try again after the release finishes publishing - pass the latest published version explicitly, e.g.: - curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 + curl -fsSL https://feynman.is/install | bash -s -- 0.2.21 EOF exit 1 fi diff --git a/website/public/install.ps1 b/website/public/install.ps1 index dd0c095..1e9a50a 100644 --- a/website/public/install.ps1 +++ b/website/public/install.ps1 @@ -110,7 +110,7 @@ This usually means the release exists, but not all platform bundles were uploade Workarounds: - try again after the release finishes publishing - pass the latest published version explicitly, e.g.: - & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 + & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.21 "@ } diff --git a/website/src/content/docs/getting-started/installation.md b/website/src/content/docs/getting-started/installation.md index 0a5bc25..e74ac94 100644 --- a/website/src/content/docs/getting-started/installation.md +++ b/website/src/content/docs/getting-started/installation.md @@ -117,13 +117,13 @@ These installers download the bundled `skills/` and `prompts/` trees plus the re The one-line installer already targets the latest tagged release. To pin an exact version, pass it explicitly: ```bash -curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 +curl -fsSL https://feynman.is/install | bash -s -- 0.2.21 ``` On Windows: ```powershell -& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 +& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.21 ``` ## Post-install setup