-
Notifications
You must be signed in to change notification settings - Fork 456
feat(pi-fff): support global config file #790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dmtrKovalenko
merged 1 commit into
dmtrKovalenko:main
from
XWIlluDelu:feat/pi-fff-config
Aug 16, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| } | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Keep the startup mode consistent with registered tools.
The session-start handler later overwrites
currentModefrom a saved entry. It can override the CLI, environment, or file mode aftertoolNamesis fixed.For example, file mode
overrideregistersgrepandfind, then a restoredtools-onlymode disables autocomplete and reports a different active mode. Resolve the restored mode before registration, or do not restore it during startup.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This behavior predates this PR and comes from #593.
mainalready fixestoolNamesduring extension registration, then restorescurrentModefrom the session insession_start. This change only routes the existing startup sources throughgetConfigValue()and adds the file as another source.Fixing the mismatch would change
/fff-modepersistence semantics, so I am leaving it out of this config-file PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@XWIlluDeluCorrect. The mismatch predates this PR. This PR only adds another startup source.The issue remains valid, but it is separate from the config-file scope and can change
/fff-modepersistence behavior. Do you want a follow-up GitHub issue for it?✏️ Learnings added
You are interacting with an AI system.