From b40c04b48c67679cff4231b2f7d4b997d4c500e9 Mon Sep 17 00:00:00 2001 From: XWIlluDelu Date: Mon, 17 Aug 2026 00:21:10 +0800 Subject: [PATCH] feat(pi-fff): support global config file --- packages/pi-fff/README.md | 36 +++++++- packages/pi-fff/src/config.ts | 100 ++++++++++++++++++++++ packages/pi-fff/src/index.ts | 86 ++++++++++++------- packages/pi-fff/src/paths.ts | 2 +- packages/pi-fff/test/config.test.ts | 84 +++++++++++++++++++ packages/pi-fff/test/extension.test.ts | 111 +++++++++++++++++++++++-- 6 files changed, 379 insertions(+), 40 deletions(-) create mode 100644 packages/pi-fff/src/config.ts create mode 100644 packages/pi-fff/test/config.test.ts diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 09aba814a..6456d55b8 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -127,7 +127,36 @@ Parameters: Mode precedence: 1. `--fff-mode ` CLI flag 2. `PI_FFF_MODE=` environment variable -3. default (`tools-and-ui`) +3. `mode` in the global config file +4. default (`tools-and-ui`) + +## Configuration + +For persistent global configuration, create `pi-fff.json` in pi's agent directory (`~/.pi/agent/pi-fff.json` by default; `PI_CODING_AGENT_DIR` is respected): + +```json +{ + "mode": "override", + "frecencyDbPath": "/path/to/frecency", + "historyDbPath": "/path/to/history", + "enableFsRootScanning": false, + "enableHomeDirScanning": true +} +``` + +All fields are optional: + +| Field | Type | Default | +|---|---|---| +| `mode` | `tools-and-ui`, `tools-only`, or `override` | `tools-and-ui` | +| `frecencyDbPath` | non-empty string | See [Data](#data) | +| `historyDbPath` | non-empty string | See [Data](#data) | +| `enableFsRootScanning` | boolean | `false` | +| `enableHomeDirScanning` | boolean | `true` | + +CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file. + +The file is global only. Project-level config cannot safely control tool names because pi decides which tools an extension registers before project configuration can be trusted. ## Flags @@ -147,11 +176,12 @@ Each path is resolved independently, in this order: 1. CLI flag — `--fff-frecency-db` / `--fff-history-db` 2. Env var — `FFF_FRECENCY_DB` / `FFF_HISTORY_DB` -3. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor: +3. Global config — `frecencyDbPath` / `historyDbPath` +4. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor: - frecency: `$XDG_CACHE_HOME/nvim/fff_nvim` - history: `$XDG_DATA_HOME/nvim/fff_queries` - `XDG_CACHE_HOME` defaults to `~/.cache` and `XDG_DATA_HOME` to `~/.local/share`; on Windows both fall back under `%LOCALAPPDATA%\nvim-data`. Only directories count — a plain file at those paths is ignored. -4. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}` +5. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}` The extension only reads these databases; it never records the agent's own searches into your Neovim history. If a database cannot be opened, the finder starts without persistence and pi shows a warning instead of failing. diff --git a/packages/pi-fff/src/config.ts b/packages/pi-fff/src/config.ts new file mode 100644 index 000000000..0af98bd3e --- /dev/null +++ b/packages/pi-fff/src/config.ts @@ -0,0 +1,100 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { piDataDir } from "./paths"; + +export const CONFIG_FILE_NAME = "pi-fff.json"; +export const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const; + +export type FffMode = (typeof VALID_MODES)[number]; + +export interface FffConfig { + mode?: FffMode; + frecencyDbPath?: string; + historyDbPath?: string; + enableFsRootScanning?: boolean; + enableHomeDirScanning?: boolean; +} + +const CONFIG_KEYS = new Set([ + "mode", + "frecencyDbPath", + "historyDbPath", + "enableFsRootScanning", + "enableHomeDirScanning", +]); + +export function loadConfig(agentDir = piDataDir()): FffConfig { + const configPath = join(agentDir, CONFIG_FILE_NAME); + let contents: string; + + try { + contents = readFileSync(configPath, "utf8"); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new Error( + `Could not read pi-fff config at ${configPath}: ${errorMessage(error)}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch (error: unknown) { + throw invalidConfig(configPath, `not valid JSON (${errorMessage(error)})`); + } + + if (!isRecord(parsed)) { + throw invalidConfig(configPath, "expected a JSON object"); + } + + for (const key of Object.keys(parsed)) { + if (!CONFIG_KEYS.has(key as keyof FffConfig)) { + throw invalidConfig(configPath, `unknown option "${key}"`); + } + } + + if (parsed.mode !== undefined && !VALID_MODES.includes(parsed.mode as FffMode)) { + throw invalidConfig(configPath, `"mode" must be one of ${VALID_MODES.join(", ")}`); + } + + validateString(configPath, parsed, "frecencyDbPath"); + validateString(configPath, parsed, "historyDbPath"); + validateBoolean(configPath, parsed, "enableFsRootScanning"); + validateBoolean(configPath, parsed, "enableHomeDirScanning"); + + return parsed as FffConfig; +} + +function invalidConfig(configPath: string, reason: string): Error { + return new Error(`Invalid pi-fff config at ${configPath}: ${reason}`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateString( + configPath: string, + config: Record, + key: "frecencyDbPath" | "historyDbPath", +): void { + const value = config[key]; + if (value !== undefined && (typeof value !== "string" || value.length === 0)) { + throw invalidConfig(configPath, `"${key}" must be a non-empty string`); + } +} + +function validateBoolean( + configPath: string, + config: Record, + key: "enableFsRootScanning" | "enableHomeDirScanning", +): void { + const value = config[key]; + if (value !== undefined && typeof value !== "boolean") { + throw invalidConfig(configPath, `"${key}" must be a boolean`); + } +} diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index c4988ce6b..120f2e068 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -22,6 +22,7 @@ import type { } from "@ff-labs/fff-node"; import { Type } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; +import { type FffMode, loadConfig, VALID_MODES } from "./config"; import { FilePickerFactory } from "./file-picker"; import { isHomeDir, resolveDbPaths } from "./paths"; import { buildQuery } from "./query"; @@ -45,11 +46,7 @@ const GREP_TIME_BUDGET_MS = 10_000; const HOME_SCAN_STATUS_KEY = "fff"; const HOME_SCAN_POLL_MS = 1_000; const HOME_SCAN_DISABLE_HINT = - "You can prevent home dir indexing with --fff-enable-home-scan=false (or FFF_ENABLE_HOME_SCAN=0)."; - -type FffMode = "tools-and-ui" | "tools-only" | "override"; - -const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"]; + "You can prevent home dir indexing with --fff-enable-home-scan=false, FFF_ENABLE_HOME_SCAN=0, or enableHomeDirScanning in pi-fff.json."; interface ToolNames { grep: string; @@ -299,44 +296,77 @@ export default function fffExtension(pi: ExtensionAPI) { let finderPromise: Promise | null = null; let activeCwd = process.cwd(); - // Mode resolution: flag > env > default - let currentMode: FffMode = - (pi.getFlag("fff-mode") as FffMode) ?? - (process.env.PI_FFF_MODE as FffMode) ?? - "tools-and-ui"; + const config = loadConfig(); + + // All startup options use the same flag > env > file > fallback order. + function getConfigValue( + flagName: string, + envName: string, + fileValue: T | undefined, + fallback: T, + parse: (value: unknown) => T | undefined = (value) => value as T, + ): T { + const flagValue = pi.getFlag(flagName); + if (flagValue !== undefined) { + const value = parse(flagValue); + if (value !== undefined) return value; + } + + const envValue = process.env[envName]; + if (envValue !== undefined) { + const value = parse(envValue); + if (value !== undefined) return value; + } + + return fileValue ?? fallback; + } + function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (value === "1" || value === "true") return true; + if (value === "0" || value === "false") return false; + return undefined; + } + + let currentMode = getConfigValue( + "fff-mode", + "PI_FFF_MODE", + config.mode, + "tools-and-ui", + ); const toolNames = resolveToolNames(currentMode); - // DB path resolution: flag > env > existing fff.nvim db > pi-local data dir. const resolvedDbPaths = resolveDbPaths({ - frecency: - (pi.getFlag("fff-frecency-db") as string | undefined) ?? - process.env.FFF_FRECENCY_DB, - history: - (pi.getFlag("fff-history-db") as string | undefined) ?? process.env.FFF_HISTORY_DB, + frecency: getConfigValue( + "fff-frecency-db", + "FFF_FRECENCY_DB", + config.frecencyDbPath, + undefined, + ), + history: getConfigValue( + "fff-history-db", + "FFF_HISTORY_DB", + config.historyDbPath, + undefined, + ), }); - // flag (boolean) > env ("1"/"true", or "0"/"false") > default. - function resolveBoolOpt(flagName: string, envName: string, fallback = false): boolean { - const flag = pi.getFlag(flagName); - if (typeof flag === "boolean") return flag; - if (typeof flag === "string") return flag === "true" || flag === "1"; - const env = process.env[envName]; - if (env === "1" || env === "true") return true; - if (env === "0" || env === "false") return false; - return fallback; - } // Root scanning opt-in: FFF refuses to init at / unless this is set. - const enableFsRootScanning = resolveBoolOpt( + const enableFsRootScanning = getConfigValue( "fff-enable-root-scan", "FFF_ENABLE_ROOT_SCAN", + config.enableFsRootScanning, + false, + parseBoolean, ); // Home dir scanning is on by default (launching pi from $HOME is a normal // flow), but configurable so users with huge $HOME trees can opt out. - const enableHomeDirScanning = resolveBoolOpt( + const enableHomeDirScanning = getConfigValue( "fff-enable-home-scan", "FFF_ENABLE_HOME_SCAN", + config.enableHomeDirScanning, true, + parseBoolean, ); function getMode(): FffMode { diff --git a/packages/pi-fff/src/paths.ts b/packages/pi-fff/src/paths.ts index c83b524a9..aa1b68e0e 100644 --- a/packages/pi-fff/src/paths.ts +++ b/packages/pi-fff/src/paths.ts @@ -52,7 +52,7 @@ function nvimDataDir(): string { return path.join(HOME_DIR, ".local", "share", "nvim"); } -function piDataDir(): string { +export function piDataDir(): string { return process.env.PI_CODING_AGENT_DIR ?? path.join(HOME_DIR, ".pi", "agent"); } diff --git a/packages/pi-fff/test/config.test.ts b/packages/pi-fff/test/config.test.ts new file mode 100644 index 000000000..60f1c57e0 --- /dev/null +++ b/packages/pi-fff/test/config.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { CONFIG_FILE_NAME, loadConfig } from "../src/config"; + +describe("loadConfig", () => { + let agentDir: string; + let configPath: string; + + beforeEach(() => { + agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fff-config-")); + configPath = path.join(agentDir, CONFIG_FILE_NAME); + }); + + afterEach(() => { + fs.rmSync(agentDir, { recursive: true, force: true }); + }); + + test("returns an empty config when the file does not exist", () => { + expect(loadConfig(agentDir)).toEqual({}); + }); + + test("loads every supported option", () => { + const config = { + mode: "override" as const, + frecencyDbPath: "/data/frecency", + historyDbPath: "/data/history", + enableFsRootScanning: true, + enableHomeDirScanning: false, + }; + writeConfig(config); + + expect(loadConfig(agentDir)).toEqual(config); + }); + + test("rejects malformed JSON", () => { + fs.writeFileSync(configPath, '{"mode":'); + + expect(() => loadConfig(agentDir)).toThrow( + `Invalid pi-fff config at ${configPath}: not valid JSON`, + ); + }); + + test("rejects non-object config", () => { + writeConfig(["override"]); + + expect(() => loadConfig(agentDir)).toThrow("expected a JSON object"); + }); + + test("rejects unknown options", () => { + writeConfig({ mode: "override", typo: true }); + + expect(() => loadConfig(agentDir)).toThrow('unknown option "typo"'); + }); + + test("rejects invalid option values", () => { + const cases: [Record, string][] = [ + [{ mode: "replace" }, '"mode" must be one of'], + [{ frecencyDbPath: "" }, '"frecencyDbPath" must be a non-empty string'], + [{ historyDbPath: false }, '"historyDbPath" must be a non-empty string'], + [{ enableFsRootScanning: 1 }, '"enableFsRootScanning" must be a boolean'], + [{ enableHomeDirScanning: "false" }, '"enableHomeDirScanning" must be a boolean'], + ]; + + for (const [config, message] of cases) { + writeConfig(config); + expect(() => loadConfig(agentDir)).toThrow(message); + } + }); + + test("reports file read failures", () => { + fs.mkdirSync(configPath); + + expect(() => loadConfig(agentDir)).toThrow( + `Could not read pi-fff config at ${configPath}`, + ); + }); + + function writeConfig(config: unknown): void { + fs.writeFileSync(configPath, JSON.stringify(config)); + } +}); diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index fdce31e53..9d1c3a5ce 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -1,5 +1,7 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; type MockFinder = { isDestroyed: boolean; @@ -97,12 +99,14 @@ const { default: fffExtension } = await import("../src/index"); type EventHandler = (...args: any[]) => unknown; -function createPi(mode?: string) { +function createPi(mode?: string, flags: Record = {}) { const events = new Map(); const commands = new Map(); const pi = { - getFlag: mock((name: string) => (name === "fff-mode" ? mode : undefined)), + getFlag: mock((name: string) => + name === "fff-mode" && mode !== undefined ? mode : flags[name], + ), on: mock((event: string, handler: EventHandler) => { events.set(event, handler); }), @@ -110,7 +114,7 @@ function createPi(mode?: string) { commands.set(name, command); }), registerFlag: mock(() => undefined), - registerTool: mock(() => undefined), + registerTool: mock((_tool: any) => undefined), appendEntry: mock(() => undefined), }; @@ -130,8 +134,8 @@ function createContext(cwd = "/tmp/workspace") { }; } -async function start(mode?: string, cwd?: string) { - const setup = createPi(mode); +async function start(mode?: string, cwd?: string, flags: Record = {}) { + const setup = createPi(mode, flags); const ctx = createContext(cwd); fffExtension(setup.pi as any); @@ -160,15 +164,106 @@ function abortOptions() { return { signal: new AbortController().signal }; } +const CONFIG_ENV_KEYS = [ + "PI_CODING_AGENT_DIR", + "PI_FFF_MODE", + "FFF_FRECENCY_DB", + "FFF_HISTORY_DB", + "FFF_ENABLE_ROOT_SCAN", + "FFF_ENABLE_HOME_SCAN", +] as const; + +const savedEnv: Record = {}; +for (const key of CONFIG_ENV_KEYS) savedEnv[key] = process.env[key]; + +const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fff-extension-")); +const configPath = path.join(agentDir, "pi-fff.json"); + beforeEach(() => { createCalls.length = 0; finders = []; mixedSearchImpl = undefined; scanProgressImpl = undefined; - delete process.env.PI_FFF_MODE; - delete process.env.FFF_ENABLE_HOME_SCAN; + + for (const key of CONFIG_ENV_KEYS) delete process.env[key]; + process.env.PI_CODING_AGENT_DIR = agentDir; + fs.rmSync(configPath, { force: true }); +}); + +afterAll(() => { + for (const key of CONFIG_ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(agentDir, { recursive: true, force: true }); }); +describe("pi-fff global config", () => { + test("applies every supported startup option", async () => { + writeConfig({ + mode: "override", + frecencyDbPath: "/config/frecency", + historyDbPath: "/config/history", + enableFsRootScanning: true, + enableHomeDirScanning: false, + }); + + const setup = await start(); + const toolNames = setup.pi.registerTool.mock.calls.map(([tool]) => tool.name); + + expect(toolNames).toContain("grep"); + expect(toolNames).toContain("find"); + expect(toolNames).not.toContain("ffgrep"); + expect(createCalls[0]).toEqual({ + basePath: "/tmp/workspace", + frecencyDbPath: "/config/frecency", + historyDbPath: "/config/history", + aiMode: true, + enableHomeDirScanning: false, + enableFsRootScanning: true, + }); + await shutdown(setup); + }); + + test("keeps flag and environment precedence", async () => { + writeConfig({ + mode: "tools-only", + frecencyDbPath: "/config/frecency", + historyDbPath: "/config/history", + enableFsRootScanning: true, + enableHomeDirScanning: false, + }); + process.env.PI_FFF_MODE = "override"; + process.env.FFF_FRECENCY_DB = "/env/frecency"; + process.env.FFF_HISTORY_DB = "/env/history"; + process.env.FFF_ENABLE_ROOT_SCAN = "1"; + process.env.FFF_ENABLE_HOME_SCAN = "1"; + + const setup = await start("tools-and-ui", undefined, { + "fff-frecency-db": "/flag/frecency", + "fff-enable-root-scan": false, + }); + const toolNames = setup.pi.registerTool.mock.calls.map(([tool]) => tool.name); + + expect(toolNames).toContain("ffgrep"); + expect(toolNames).toContain("fffind"); + expect(createCalls[0]).toEqual({ + basePath: "/tmp/workspace", + frecencyDbPath: "/flag/frecency", + historyDbPath: "/env/history", + aiMode: true, + enableHomeDirScanning: true, + enableFsRootScanning: false, + }); + await shutdown(setup); + }); +}); + +function writeConfig(config: Record): void { + fs.writeFileSync(configPath, JSON.stringify(config)); +} + // Regression for #743: launching from $HOME must be visible and interruptible. describe("pi-fff $HOME scan warning", () => { test("warns and pins a status when cwd is $HOME", async () => {