feat(pi-fff): support global config file (#790)

This commit is contained in:
XWIlluDelu
2026-08-17 01:39:50 +08:00
committed by GitHub
parent f4def12a1c
commit be2dd8d568
6 changed files with 379 additions and 40 deletions
+33 -3
View File
@@ -127,7 +127,36 @@ Parameters:
Mode precedence:
1. `--fff-mode <mode>` CLI flag
2. `PI_FFF_MODE=<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.
+100
View File
@@ -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<keyof FffConfig>([
"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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateString(
configPath: string,
config: Record<string, unknown>,
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<string, unknown>,
key: "enableFsRootScanning" | "enableHomeDirScanning",
): void {
const value = config[key];
if (value !== undefined && typeof value !== "boolean") {
throw invalidConfig(configPath, `"${key}" must be a boolean`);
}
}
+58 -28
View File
@@ -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<FileFinderApi> | 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<T>(
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 {
+1 -1
View File
@@ -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");
}
+84
View File
@@ -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, unknown>, 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));
}
});
+103 -8
View File
@@ -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<string, unknown> = {}) {
const events = new Map<string, EventHandler>();
const commands = new Map<string, any>();
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<string, unknown> = {}) {
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<string, string | undefined> = {};
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<string, unknown>): 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 () => {