From 96234425ba9d87e526c648c2194f7ac43727e3bf Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Thu, 9 Apr 2026 10:27:23 -0700 Subject: [PATCH] harden installers rendering and dependency hygiene --- CHANGELOG.md | 9 ++++++++ extensions/research-tools/header.ts | 28 +++++++++++----------- package-lock.json | 30 ++++++++++++------------ package.json | 22 ++++++++++++++++++ scripts/install/install-skills.ps1 | 2 +- scripts/install/install-skills.sh | 20 ++++++++-------- src/pi/launch.ts | 14 +++++++---- src/pi/web-access.ts | 6 +++-- tests/pi-launch.test.ts | 9 ++++++++ tests/pi-web-access.test.ts | 4 ++++ website/package-lock.json | 36 ++++++++++++++--------------- website/package.json | 11 +++++++++ website/public/install-skills | 20 ++++++++-------- website/public/install-skills.ps1 | 2 +- 14 files changed, 138 insertions(+), 75 deletions(-) create mode 100644 tests/pi-launch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b55f7..7128fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,3 +131,12 @@ Use this file to track chronology, not release notes. Keep entries short, factua - Failed / learned: The skills-only install issue was not just docs drift; the shipped `SKILL.md` files referenced prompt paths that only made sense after installation, so the repo needed both path normalization and packaging changes. - Blockers: Remote issue/PR closure and merge actions still depend on the final reviewed branch state being pushed. - Next: Push the validated fixes, close the duplicate Windows/reporting issues they supersede, reject the promotional ValiChord PR explicitly, and then review whether the remaining docs-only or feature PRs should be merged separately. + +### 2026-04-09 10:28 PDT — verification-and-security-pass + +- Objective: Run a deeper install/security verification pass against the post-cleanup `0.2.17` tree instead of assuming the earlier targeted fixes covered the shipped artifacts. +- Changed: Reworked `extensions/research-tools/header.ts` to use `@mariozechner/pi-tui` width-aware helpers for truncation/wrapping so wide Unicode text does not overflow custom header rows; changed `src/pi/launch.ts` to stop mirroring child crash signals back onto the parent process and instead emit a conventional exit code; added `FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL` overrides to the skills installers for pre-release smoke testing; aligned root and website dependency trees with patched transitive versions using npm `overrides`; fixed `src/pi/web-access.ts` so `search status` respects `FEYNMAN_HOME` semantics instead of hardcoding the current shell home directory; added `tests/pi-launch.test.ts`. +- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`, `cd website && npm run build`, `npm run build:native-bundle`; smoke-tested `scripts/install/install.sh` against a locally served `dist/release/feynman-0.2.17-darwin-arm64.tar.gz`; smoke-tested `scripts/install/install-skills.sh` against a local source archive; confirmed installed `feynman --version`, `feynman --help`, `feynman doctor`, and packaged `feynman search status` work from the installed bundle; `npm audit --omit=dev` is clean in the root app and website after overrides. +- Failed / learned: The first packaged `search status` smoke test still showed the user home path because the native bundle had been built before the `FEYNMAN_HOME` path fix; rebuilding the native bundle resolved that mismatch. +- Blockers: PowerShell runtime was unavailable locally, so Windows installer execution remained code-path validated rather than actually executed. +- Next: Push the second-pass hardening commit, then keep issue `#46` and issue `#47` open until users on the affected Linux/CJK environments confirm whether the launcher/header fixes fully resolve them. diff --git a/extensions/research-tools/header.ts b/extensions/research-tools/header.ts index 4871c9c..51a43c3 100644 --- a/extensions/research-tools/header.ts +++ b/extensions/research-tools/header.ts @@ -4,6 +4,7 @@ import { execSync } from "node:child_process"; import { resolve as resolvePath } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { APP_ROOT, @@ -11,10 +12,8 @@ import { FEYNMAN_VERSION, } from "./shared.js"; -const ANSI_RE = /\x1b\[[0-9;]*m/g; - function visibleLength(text: string): number { - return text.replace(ANSI_RE, "").length; + return visibleWidth(text); } function formatHeaderPath(path: string): string { @@ -23,10 +22,8 @@ function formatHeaderPath(path: string): string { } function truncateVisible(text: string, maxVisible: number): string { - const raw = text.replace(ANSI_RE, ""); - if (raw.length <= maxVisible) return text; - if (maxVisible <= 3) return ".".repeat(maxVisible); - return `${raw.slice(0, maxVisible - 3)}...`; + if (visibleWidth(text) <= maxVisible) return text; + return truncateToWidth(text, maxVisible, maxVisible <= 3 ? "" : "..."); } function wrapWords(text: string, maxW: number): string[] { @@ -34,12 +31,12 @@ function wrapWords(text: string, maxW: number): string[] { const lines: string[] = []; let cur = ""; for (let word of words) { - if (word.length > maxW) { + if (visibleWidth(word) > maxW) { if (cur) { lines.push(cur); cur = ""; } - word = maxW > 3 ? `${word.slice(0, maxW - 1)}…` : word.slice(0, maxW); + word = truncateToWidth(word, maxW, maxW > 3 ? "…" : ""); } const test = cur ? `${cur} ${word}` : word; - if (cur && test.length > maxW) { + if (cur && visibleWidth(test) > maxW) { lines.push(cur); cur = word; } else { @@ -56,9 +53,10 @@ function padRight(text: string, width: number): string { } function centerText(text: string, width: number): string { - if (text.length >= width) return text.slice(0, width); - const left = Math.floor((width - text.length) / 2); - const right = width - text.length - left; + const textWidth = visibleWidth(text); + if (textWidth >= width) return truncateToWidth(text, width, ""); + const left = Math.floor((width - textWidth) / 2); + const right = width - textWidth - left; return `${" ".repeat(left)}${text}${" ".repeat(right)}`; } @@ -287,8 +285,8 @@ export function installFeynmanHeader( if (activity) { const maxActivityLen = leftW * 2; - const trimmed = activity.length > maxActivityLen - ? `${activity.slice(0, maxActivityLen - 1)}…` + const trimmed = visibleWidth(activity) > maxActivityLen + ? truncateToWidth(activity, maxActivityLen, "…") : activity; leftLines.push(""); leftLines.push(theme.fg("accent", theme.bold("Last Activity"))); diff --git a/package-lock.json b/package-lock.json index 3e042a0..069ae7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1265,9 +1265,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2530,9 +2530,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz", + "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2578,9 +2578,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3623,9 +3623,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4218,9 +4218,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", diff --git a/package.json b/package.json index 0c124e4..6693e77 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,28 @@ "@sinclair/typebox": "^0.34.48", "dotenv": "^17.3.1" }, + "overrides": { + "basic-ftp": "5.2.1", + "@modelcontextprotocol/sdk": { + "@hono/node-server": "1.19.13", + "hono": "4.12.12" + }, + "express": { + "router": { + "path-to-regexp": "8.4.2" + } + }, + "proxy-agent": { + "pac-proxy-agent": { + "get-uri": { + "basic-ftp": "5.2.1" + } + } + }, + "minimatch": { + "brace-expansion": "5.0.5" + } + }, "devDependencies": { "@types/node": "^25.5.0", "tsx": "^4.21.0", diff --git a/scripts/install/install-skills.ps1 b/scripts/install/install-skills.ps1 index b081451..9125af4 100644 --- a/scripts/install/install-skills.ps1 +++ b/scripts/install/install-skills.ps1 @@ -46,7 +46,7 @@ function Resolve-VersionMetadata { return [PSCustomObject]@{ ResolvedVersion = $resolvedVersion GitRef = "v$resolvedVersion" - DownloadUrl = "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" + DownloadUrl = if ($env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL) { $env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL } else { "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" } } } diff --git a/scripts/install/install-skills.sh b/scripts/install/install-skills.sh index f832ae9..1084769 100644 --- a/scripts/install/install-skills.sh +++ b/scripts/install/install-skills.sh @@ -146,15 +146,17 @@ archive_metadata="$(resolve_version)" resolved_version="$(printf '%s\n' "$archive_metadata" | sed -n '1p')" git_ref="$(printf '%s\n' "$archive_metadata" | sed -n '2p')" -archive_url="" -case "$git_ref" in - main) - archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz" - ;; - v*) - archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz" - ;; -esac +archive_url="${FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL:-}" +if [ -z "$archive_url" ]; then + case "$git_ref" in + main) + archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz" + ;; + v*) + archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz" + ;; + esac +fi if [ -z "$archive_url" ]; then echo "Could not resolve a download URL for ref: $git_ref" >&2 diff --git a/src/pi/launch.ts b/src/pi/launch.ts index 097ac2d..dbdfc74 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -1,9 +1,15 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; +import { constants } from "node:os"; import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths, toNodeImportSpecifier } from "./runtime.js"; import { ensureSupportedNodeVersion } from "../system/node-version.js"; +export function exitCodeFromSignal(signal: NodeJS.Signals): number { + const signalNumber = constants.signals[signal]; + return typeof signalNumber === "number" ? 128 + signalNumber : 1; +} + export async function launchPiChat(options: PiRuntimeOptions): Promise { ensureSupportedNodeVersion(); @@ -36,11 +42,9 @@ export async function launchPiChat(options: PiRuntimeOptions): Promise { child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { - try { - process.kill(process.pid, signal); - } catch { - process.exitCode = 1; - } + console.error(`feynman terminated because the Pi child exited with ${signal}.`); + process.exitCode = exitCodeFromSignal(signal); + resolvePromise(); return; } process.exitCode = code ?? 0; diff --git a/src/pi/web-access.ts b/src/pi/web-access.ts index e4f1071..0dca2e8 100644 --- a/src/pi/web-access.ts +++ b/src/pi/web-access.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { getFeynmanHome } from "../config/paths.js"; export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini"; @@ -26,8 +27,9 @@ export type PiWebAccessStatus = { note: string; }; -export function getPiWebSearchConfigPath(home = process.env.HOME ?? homedir()): string { - return resolve(home, ".feynman", "web-search.json"); +export function getPiWebSearchConfigPath(home?: string): string { + const feynmanHome = home ? resolve(home, ".feynman") : getFeynmanHome(); + return resolve(feynmanHome, "web-search.json"); } function normalizeProvider(value: unknown): PiWebSearchProvider | undefined { diff --git a/tests/pi-launch.test.ts b/tests/pi-launch.test.ts new file mode 100644 index 0000000..ec0dc36 --- /dev/null +++ b/tests/pi-launch.test.ts @@ -0,0 +1,9 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { exitCodeFromSignal } from "../src/pi/launch.js"; + +test("exitCodeFromSignal maps POSIX signals to conventional shell exit codes", () => { + assert.equal(exitCodeFromSignal("SIGTERM"), 143); + assert.equal(exitCodeFromSignal("SIGSEGV"), 139); +}); diff --git a/tests/pi-web-access.test.ts b/tests/pi-web-access.test.ts index 99fd123..98e8ee2 100644 --- a/tests/pi-web-access.test.ts +++ b/tests/pi-web-access.test.ts @@ -18,6 +18,10 @@ test("loadPiWebAccessConfig returns empty config when Pi web config is missing", assert.deepEqual(loadPiWebAccessConfig(configPath), {}); }); +test("getPiWebSearchConfigPath respects FEYNMAN_HOME semantics", () => { + assert.equal(getPiWebSearchConfigPath("/tmp/custom-home"), "/tmp/custom-home/.feynman/web-search.json"); +}); + test("getPiWebAccessStatus reads Pi web-access config directly", () => { const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-")); const configPath = getPiWebSearchConfigPath(root); diff --git a/website/package-lock.json b/website/package-lock.json index 1eb6825..e5b6273 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "website", "version": "0.0.1", - "engines": { - "node": ">=20.19.0" - }, "dependencies": { "@astrojs/react": "^4.4.2", "@fontsource-variable/ibm-plex-sans": "^5.2.8", @@ -39,6 +36,9 @@ "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.1" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/@astrojs/compiler": { @@ -1369,9 +1369,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -6186,9 +6186,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/depd": { @@ -7677,9 +7677,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -10994,9 +10994,9 @@ } }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -12367,9 +12367,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/website/package.json b/website/package.json index a1b56e9..a4671e5 100644 --- a/website/package.json +++ b/website/package.json @@ -33,6 +33,17 @@ "tailwindcss": "^4.2.1", "tw-animate-css": "^1.4.0" }, + "overrides": { + "@modelcontextprotocol/sdk": { + "@hono/node-server": "1.19.13", + "hono": "4.12.12" + }, + "router": { + "path-to-regexp": "8.4.2" + }, + "defu": "6.1.7", + "vite": "6.4.2" + }, "devDependencies": { "@eslint/js": "^9.39.4", "eslint": "^9.39.4", diff --git a/website/public/install-skills b/website/public/install-skills index f832ae9..1084769 100644 --- a/website/public/install-skills +++ b/website/public/install-skills @@ -146,15 +146,17 @@ archive_metadata="$(resolve_version)" resolved_version="$(printf '%s\n' "$archive_metadata" | sed -n '1p')" git_ref="$(printf '%s\n' "$archive_metadata" | sed -n '2p')" -archive_url="" -case "$git_ref" in - main) - archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz" - ;; - v*) - archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz" - ;; -esac +archive_url="${FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL:-}" +if [ -z "$archive_url" ]; then + case "$git_ref" in + main) + archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz" + ;; + v*) + archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz" + ;; + esac +fi if [ -z "$archive_url" ]; then echo "Could not resolve a download URL for ref: $git_ref" >&2 diff --git a/website/public/install-skills.ps1 b/website/public/install-skills.ps1 index b081451..9125af4 100644 --- a/website/public/install-skills.ps1 +++ b/website/public/install-skills.ps1 @@ -46,7 +46,7 @@ function Resolve-VersionMetadata { return [PSCustomObject]@{ ResolvedVersion = $resolvedVersion GitRef = "v$resolvedVersion" - DownloadUrl = "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" + DownloadUrl = if ($env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL) { $env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL } else { "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" } } }