diff --git a/flake.nix b/flake.nix index 8e24a45..b54446d 100644 --- a/flake.nix +++ b/flake.nix @@ -97,6 +97,7 @@ stylix, voidrice, wetter, + wrappers, meteora, ... }: @@ -314,7 +315,10 @@ radio-news = prev.callPackage packages/radio-news { }; untilport = prev.callPackage packages/untilport.nix { }; weechat-declarative = prev.callPackage packages/weechat-declarative.nix { }; - pi = prev.callPackage packages/pi.nix { }; + pi = prev.callPackage packages/pi { + pkgs = final; + inherit wrappers; + }; # my packages betacode = prev.callPackage packages/betacode.nix { }; diff --git a/packages/pi.nix b/packages/pi.nix deleted file mode 100644 index e78db2b..0000000 --- a/packages/pi.nix +++ /dev/null @@ -1,69 +0,0 @@ -{ - runCommand, - nodejs, - writeShellApplication, - lib, - jq, - cacert, - pi-llm, -}: -let - # Pre-install pi plugins into a fake npm global prefix - pluginPrefixRaw = - runCommand "pi-plugins-raw" - { - nativeBuildInputs = [ - nodejs - cacert - ]; - outputHashMode = "recursive"; - outputHashAlgo = "sha256"; - outputHash = "sha256-+XaHU/Ale2YLQmdvfG73nG+tjRkWyb27bdLgI3JFLlU="; - impureEnvVars = [ - "http_proxy" - "https_proxy" - ]; - SSL_CERT_FILE = "${cacert}/etc/ssl/certs/ca-bundle.crt"; - } - '' - export HOME=$TMPDIR - export npm_config_prefix=$out - npm install -g pi-hooks shitty-extensions - ''; - - # Remove the resistance extension (annoying terminator quote widget) - pluginPrefix = runCommand "pi-plugins" { } '' - cp -a ${pluginPrefixRaw} $out - chmod -R u+w $out - pkg=$out/lib/node_modules/shitty-extensions/package.json - ${lib.getExe jq} '.pi.extensions |= map(select(contains("resistance") | not))' "$pkg" > "$pkg.tmp" - mv "$pkg.tmp" "$pkg" - ''; -in -writeShellApplication { - name = "pi"; - runtimeInputs = [ nodejs ]; - text = '' - set -efu - export npm_config_prefix="${pluginPrefix}" - - # Ensure settings.json has our plugins listed - SETTINGS_DIR="''${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" - SETTINGS_FILE="$SETTINGS_DIR/settings.json" - mkdir -p "$SETTINGS_DIR" - - # Add packages to settings if not already present - if [ ! -f "$SETTINGS_FILE" ]; then - echo '{"packages":["npm:pi-hooks","npm:shitty-extensions"]}' > "$SETTINGS_FILE" - else - for pkg in "npm:pi-hooks" "npm:shitty-extensions"; do - if ! grep -q "$pkg" "$SETTINGS_FILE"; then - ${lib.getExe jq} --arg p "$pkg" '.packages = ((.packages // []) + [$p] | unique)' "$SETTINGS_FILE" > "$SETTINGS_FILE.tmp" - mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" - fi - done - fi - - exec ${lib.getExe pi-llm} "$@" - ''; -} diff --git a/packages/pi/default.nix b/packages/pi/default.nix new file mode 100644 index 0000000..0cd537a --- /dev/null +++ b/packages/pi/default.nix @@ -0,0 +1,70 @@ +{ pkgs, wrappers }: +let + piWrapper = wrappers.lib.wrapModule { + imports = [ ./module.nix ]; + inherit pkgs; + + settings = { + packages = [ + { + source = "npm:pi-hooks"; + version = "1.0.3"; + hash = "sha256-jU3akfqsWgjvOG+8+Md2qEzkXp48LUaXVncpUMXxy7s="; + deps = [ + { + source = "npm:shell-quote"; + version = "1.8.3"; + hash = "sha256-32QLNUuvjigj1scqLlCVFTfgS3MHm9dBjPk9iVB+IsE="; + } + { + source = "npm:vscode-languageserver-protocol"; + version = "3.17.5"; + hash = "sha256-dHPrLSFj8/i+oJZE+dgDeJoZXllrZdOUbEFX5YPjzMg="; + deps = [ + { + source = "npm:vscode-jsonrpc"; + version = "8.2.0"; + hash = "sha256-PaRFMcOY8VRQdMtyjjWagi81ufiscXHIR/QvByi5x8s="; + } + { + source = "npm:vscode-languageserver-types"; + version = "3.17.5"; + hash = "sha256-1nP55/i75RNRvlHFjzLU3PqXpnDruGvGMzaDlMYJysA="; + } + ]; + } + ]; + } + { + source = "npm:shitty-extensions"; + version = "1.0.9"; + hash = "sha256-g26MZ5x4HUcDai4SXPaOEhqgGGqzAI68znnsCbKJv7E="; + extensions = [ "!extensions/resistance.ts" ]; + } + ]; + extensions = [ + ./questionnaire.ts + ]; + defaultProvider = "anthropic"; + defaultModel = "claude-opus-4-6"; + defaultThinkingLevel = "medium"; + permissionLevel = "low"; + permissionMode = "ask"; + permissionConfig.overrides.minimal = [ + "nix build *" + "nix eval *" + "nix fmt *" + ]; + }; + + pluginOverrides = '' + # Fix keybinding conflicts in extension source + ${pkgs.gnused}/bin/sed -i 's/"ctrl+u"/"ctrl+shift+u"/' $out/lib/node_modules/shitty-extensions/extensions/ultrathink.ts + ${pkgs.gnused}/bin/sed -i 's/"ctrl+r"/"ctrl+shift+r"/' $out/lib/node_modules/shitty-extensions/extensions/speedreading.ts + + # Remove a-nach-b skill + rm -rf $out/lib/node_modules/shitty-extensions/skills/a-nach-b + ''; + }; +in +piWrapper.wrapper diff --git a/packages/pi/module.nix b/packages/pi/module.nix new file mode 100644 index 0000000..0f971ef --- /dev/null +++ b/packages/pi/module.nix @@ -0,0 +1,150 @@ +{ + config, + lib, + ... +}: +let + pkgs = config.pkgs; + + # Extract npm packages with hashes from settings.packages + npmPackages = lib.filter (p: builtins.isAttrs p && p ? hash) (config.settings.packages or [ ]); + + # Fetch an npm tarball and unpack it into lib/node_modules/ + fetchPlugin = + pkg: + let + source = pkg.source or pkg; + name = lib.removePrefix "npm:" source; + tarball = pkgs.fetchurl { + url = "https://registry.npmjs.org/${name}/-/${name}-${pkg.version}.tgz"; + hash = pkg.hash; + }; + deps = map fetchPlugin (pkg.deps or [ ]); + in + pkgs.runCommand "pi-plugin-${name}" + { + nativeBuildInputs = [ + pkgs.gnutar + pkgs.gzip + ]; + } + '' + mkdir -p $out/lib/node_modules/${name} + tar xzf ${tarball} --strip-components=1 -C $out/lib/node_modules/${name} + ${lib.concatMapStringsSep "\n" ( + dep: + "mkdir -p $out/lib/node_modules/${name}/node_modules" + + "\ncp -a ${dep}/lib/node_modules/* $out/lib/node_modules/${name}/node_modules/" + + "\ncp -a ${dep}/lib/node_modules/* $out/lib/node_modules/" + ) deps} + ''; + + fetchedPlugins = map fetchPlugin npmPackages; + + # Strip hash and null-valued attrs from package entries before writing settings.json + cleanPackage = + p: + if builtins.isAttrs p then + lib.filterAttrs (k: v: k != "hash" && k != "version" && k != "deps" && v != null) p + else + p; + + cleanSettings = config.settings // { + packages = map cleanPackage (config.settings.packages or [ ]); + }; + + settingsFile = pkgs.writeText "pi-settings.json" (builtins.toJSON cleanSettings); +in +{ + _class = "wrapper"; + + options = { + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = (pkgs.formats.json { }).type; + options.packages = lib.mkOption { + type = lib.types.listOf ( + lib.types.either lib.types.str ( + lib.types.submodule { + freeformType = (pkgs.formats.json { }).type; + options.hash = lib.mkOption { + type = lib.types.str; + description = "Fixed-output hash for fetching this npm package."; + }; + options.source = lib.mkOption { + type = lib.types.str; + description = "Package source (e.g. npm:package-name)."; + }; + options.version = lib.mkOption { + type = lib.types.str; + description = "Exact version to pin (e.g. 1.0.3). Ensures reproducible fetching."; + }; + options.deps = lib.mkOption { + type = lib.types.listOf (lib.types.attrsOf lib.types.anything); + default = [ ]; + description = "Transitive npm dependencies as {source, version, hash} attrsets."; + }; + options.extensions = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + description = '' + Extension filter patterns following pi's native syntax. + When null (default), all extensions from the package are loaded. + An empty list explicitly disables all extensions. + Patterns use prefixes to control inclusion: + - `"!pattern"` — exclude matching extensions + - `"+pattern"` — force-include (overrides excludes) + - `"-pattern"` — force-exclude (overrides includes) + - `"path"` — plain path to include + ''; + example = [ + "!./extensions/resistance.ts" + ]; + }; + } + ) + ); + default = [ ]; + description = "Pi packages list. Object entries may include a `hash` for Nix fetching."; + }; + }; + default = { }; + description = "Pi settings.json content."; + }; + + pluginOverrides = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Shell commands to run after assembling the plugin prefix. + The variable `$out` points to the mutable copy. + ''; + }; + + pluginPrefix = lib.mkOption { + type = lib.types.package; + description = "The final npm prefix with all plugins merged and overrides applied."; + default = pkgs.runCommand "pi-plugins" { } '' + mkdir -p $out + ${lib.concatMapStringsSep "\n" (p: "cp -a --no-preserve=mode ${p}/* $out/") fetchedPlugins} + ${lib.optionalString (config.pluginOverrides != "") config.pluginOverrides} + ''; + }; + }; + + config.package = lib.mkDefault pkgs.pi-llm; + + config.extraPackages = [ pkgs.nodejs ]; + + config.env.npm_config_prefix = "${config.pluginPrefix}"; + config.env.PI_SETTINGS_FILE = "${settingsFile}"; + + config.meta.maintainers = [ + { + name = "lassulus"; + github = "lassulus"; + githubId = 621375; + } + ]; + config.meta.platforms = lib.platforms.all; +} diff --git a/packages/pi/patch-permission-sound.py b/packages/pi/patch-permission-sound.py new file mode 100644 index 0000000..2a18e5d --- /dev/null +++ b/packages/pi/patch-permission-sound.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Patch pi-hooks permission extension to play peon sounds via pw-play on Linux.""" +import re +import sys + +ts_file = sys.argv[1] +pw_play = sys.argv[2] + +src = open(ts_file).read() + +# Use a lambda for replacement to avoid re.sub interpreting backslash sequences +replacement = r"""function playPermissionSound(): void { + const isMac = process.platform === "darwin"; + if (isMac) { + exec('afplay /System/Library/Sounds/Funk.aiff 2>/dev/null', (err) => { + if (err) process.stdout.write("\x07"); + }); + } else { + const n = Math.floor(Math.random() * 4) + 1; + exec(`""" + pw_play + r""" "$HOME/src/sounds/games/Warcraft III/Units/Orc/Peon/PeonWhat${n}.wav"`, () => {}); + } +}""" + +result = re.sub( + r'function playPermissionSound\(\): void \{.*?\n\}', + lambda m: replacement, + src, + flags=re.DOTALL, +) + +if result == src: + print("ERROR: playPermissionSound function not found in", ts_file, file=sys.stderr) + sys.exit(1) + +open(ts_file, 'w').write(result) diff --git a/packages/pi/questionnaire.ts b/packages/pi/questionnaire.ts new file mode 100644 index 0000000..f259345 --- /dev/null +++ b/packages/pi/questionnaire.ts @@ -0,0 +1,463 @@ +/** + * Questionnaire Tool - Ask the user multiple choice questions + * + * Registers a "questionnaire" tool the LLM can call to ask single or multiple + * questions with an interactive TUI. Based on mic92's pi-agent-extensions. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + Editor, + type EditorTheme, + Key, + matchesKey, + Text, + truncateToWidth, +} from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface QuestionOption { + value: string; + label: string; + description?: string; +} + +type RenderOption = QuestionOption & { isOther?: boolean }; + +interface Question { + id: string; + label: string; + prompt: string; + options: QuestionOption[]; + allowOther: boolean; +} + +interface Answer { + id: string; + value: string; + label: string; + wasCustom: boolean; + index?: number; +} + +interface QuestionnaireResult { + questions: Question[]; + answers: Answer[]; + cancelled: boolean; +} + +const QuestionOptionSchema = Type.Object({ + value: Type.String({ description: "The value returned when selected" }), + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional( + Type.String({ description: "Optional description shown below label" }), + ), +}); + +const QuestionSchema = Type.Object({ + id: Type.String({ description: "Unique identifier for this question" }), + label: Type.Optional( + Type.String({ + description: + "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)", + }), + ), + prompt: Type.String({ description: "The full question text to display" }), + options: Type.Array(QuestionOptionSchema, { + description: "Available options to choose from", + }), + allowOther: Type.Optional( + Type.Boolean({ + description: "Allow 'Type something' option (default: true)", + }), + ), +}); + +const QuestionnaireParams = Type.Object({ + questions: Type.Array(QuestionSchema, { + description: "Questions to ask the user", + }), +}); + +function errorResult( + message: string, + questions: Question[] = [], +): { + content: { type: "text"; text: string }[]; + details: QuestionnaireResult; +} { + return { + content: [{ type: "text", text: message }], + details: { questions, answers: [], cancelled: true }, + }; +} + +export default function questionnaire(pi: ExtensionAPI) { + pi.registerTool({ + name: "questionnaire", + label: "Questionnaire", + description: + "Ask the user one or more multiple choice questions. Use for clarifying requirements, getting preferences, confirming decisions, or quizzing. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.", + parameters: QuestionnaireParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return errorResult( + "Error: UI not available (running in non-interactive mode)", + ); + } + if (params.questions.length === 0) { + return errorResult("Error: No questions provided"); + } + + const questions: Question[] = params.questions.map((q, i) => ({ + ...q, + label: q.label || `Q${i + 1}`, + allowOther: q.allowOther !== false, + })); + + const isMulti = questions.length > 1; + const totalTabs = questions.length + 1; + + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => { + let currentTab = 0; + let optionIndex = 0; + let inputMode = false; + let inputQuestionId: string | null = null; + let cachedLines: string[] | undefined; + const answers = new Map(); + + const editorTheme: EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme); + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function submit(cancelled: boolean) { + done({ + questions, + answers: Array.from(answers.values()), + cancelled, + }); + } + + function currentQuestion(): Question | undefined { + return questions[currentTab]; + } + + function currentOptions(): RenderOption[] { + const q = currentQuestion(); + if (!q) return []; + const opts: RenderOption[] = [...q.options]; + if (q.allowOther) { + opts.push({ + value: "__other__", + label: "Type something.", + isOther: true, + }); + } + return opts; + } + + function allAnswered(): boolean { + return questions.every((q) => answers.has(q.id)); + } + + function advanceAfterAnswer() { + if (!isMulti) { + submit(false); + return; + } + if (currentTab < questions.length - 1) { + currentTab++; + } else { + currentTab = questions.length; + } + optionIndex = 0; + refresh(); + } + + function saveAnswer( + questionId: string, + value: string, + label: string, + wasCustom: boolean, + index?: number, + ) { + answers.set(questionId, { + id: questionId, + value, + label, + wasCustom, + index, + }); + } + + editor.onSubmit = (value) => { + if (!inputQuestionId) return; + const trimmed = value.trim() || "(no response)"; + saveAnswer(inputQuestionId, trimmed, trimmed, true); + inputMode = false; + inputQuestionId = null; + editor.setText(""); + advanceAfterAnswer(); + }; + + function handleInput(data: string) { + if (inputMode) { + if (matchesKey(data, Key.escape)) { + inputMode = false; + inputQuestionId = null; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + const q = currentQuestion(); + const opts = currentOptions(); + + if (isMulti) { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + currentTab = (currentTab + 1) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + if ( + matchesKey(data, Key.shift("tab")) || + matchesKey(data, Key.left) + ) { + currentTab = (currentTab - 1 + totalTabs) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + } + + if (currentTab === questions.length) { + if (matchesKey(data, Key.enter) && allAnswered()) { + submit(false); + } else if (matchesKey(data, Key.escape)) { + submit(true); + } + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(opts.length - 1, optionIndex + 1); + refresh(); + return; + } + + if (matchesKey(data, Key.enter) && q) { + const opt = opts[optionIndex]; + if (opt.isOther) { + inputMode = true; + inputQuestionId = q.id; + editor.setText(""); + refresh(); + return; + } + saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1); + advanceAfterAnswer(); + return; + } + + if (matchesKey(data, Key.escape)) { + submit(true); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const q = currentQuestion(); + const opts = currentOptions(); + + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + + if (isMulti) { + const tabs: string[] = ["← "]; + for (let i = 0; i < questions.length; i++) { + const isActive = i === currentTab; + const isAnswered = answers.has(questions[i].id); + const lbl = questions[i].label; + const box = isAnswered ? "■" : "□"; + const color = isAnswered ? "success" : "muted"; + const text = ` ${box} ${lbl} `; + const styled = isActive + ? theme.bg("selectedBg", theme.fg("text", text)) + : theme.fg(color, text); + tabs.push(`${styled} `); + } + const canSubmit = allAnswered(); + const isSubmitTab = currentTab === questions.length; + const submitText = " ✓ Submit "; + const submitStyled = isSubmitTab + ? theme.bg("selectedBg", theme.fg("text", submitText)) + : theme.fg(canSubmit ? "success" : "dim", submitText); + tabs.push(`${submitStyled} →`); + add(` ${tabs.join("")}`); + lines.push(""); + } + + function renderOptions() { + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + const color = selected ? "accent" : "text"; + if (isOther && inputMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else { + add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`)); + } + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + } + + if (inputMode && q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + renderOptions(); + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + lines.push(""); + add(theme.fg("dim", " Enter to submit • Esc to cancel")); + } else if (currentTab === questions.length) { + add(theme.fg("accent", theme.bold(" Ready to submit"))); + lines.push(""); + for (const question of questions) { + const answer = answers.get(question.id); + if (answer) { + const prefix = answer.wasCustom ? "(wrote) " : ""; + add( + `${theme.fg("muted", ` ${question.label}: `)}${theme.fg( + "text", + prefix + answer.label, + )}`, + ); + } + } + lines.push(""); + if (allAnswered()) { + add(theme.fg("success", " Press Enter to submit")); + } else { + const missing = questions + .filter((q) => !answers.has(q.id)) + .map((q) => q.label) + .join(", "); + add(theme.fg("warning", ` Unanswered: ${missing}`)); + } + } else if (q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + renderOptions(); + } + + lines.push(""); + if (!inputMode) { + const help = isMulti + ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" + : " ↑↓ navigate • Enter select • Esc cancel"; + add(theme.fg("dim", help)); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }, + ); + + if (result.cancelled) { + return { + content: [{ type: "text", text: "User cancelled the questionnaire" }], + details: result, + }; + } + + const answerLines = result.answers.map((a) => { + const qLabel = questions.find((q) => q.id === a.id)?.label || a.id; + if (a.wasCustom) { + return `${qLabel}: user wrote: ${a.label}`; + } + return `${qLabel}: user selected: ${a.index}. ${a.label}`; + }); + + return { + content: [{ type: "text", text: answerLines.join("\n") }], + details: result, + }; + }, + + renderCall(args, theme) { + const qs = (args.questions as Question[]) || []; + const count = qs.length; + const labels = qs.map((q) => q.label || q.id).join(", "); + let text = theme.fg("toolTitle", theme.bold("questionnaire ")); + text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); + if (labels) { + text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuestionnaireResult | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + if (details.cancelled) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + const lines = details.answers.map((a) => { + if (a.wasCustom) { + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`; + } + const display = a.index ? `${a.index}. ${a.label}` : a.label; + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`; + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/packages/pi/settings-env.patch b/packages/pi/settings-env.patch new file mode 100644 index 0000000..fa4a991 --- /dev/null +++ b/packages/pi/settings-env.patch @@ -0,0 +1,20 @@ +--- /tmp/settings-manager.js.orig 2026-02-18 23:29:05.967146474 +0100 ++++ /tmp/settings-manager.js 2026-02-18 23:29:12.452952321 +0100 +@@ -97,7 +97,16 @@ + /** Create a SettingsManager that loads from files */ + static create(cwd = process.cwd(), agentDir = getAgentDir()) { + const storage = new FileSettingsStorage(cwd, agentDir); +- return SettingsManager.fromStorage(storage); ++ const manager = SettingsManager.fromStorage(storage); ++ const baseFile = process.env.PI_SETTINGS_FILE; ++ if (baseFile) { ++ try { ++ const baseSettings = JSON.parse(readFileSync(baseFile, "utf-8")); ++ manager.globalSettings = deepMergeSettings(manager.globalSettings, baseSettings); ++ manager.settings = deepMergeSettings(manager.globalSettings, manager.projectSettings); ++ } catch (e) {} ++ } ++ return manager; + } + /** Create a SettingsManager from an arbitrary storage backend */ + static fromStorage(storage) {