feat(pi-fff): support global config file (#790)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user