@homer0/pi-config

Config management building block for Pi

Packages

Package details

extension

Install @homer0/pi-config from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@homer0/pi-config
Package
@homer0/pi-config
Version
1.3.0
Published
Oct 29, 2026
Downloads
243/mo · 243/wk
Author
homer0
License
MIT
Types
extension
Size
192.6 KB
Dependencies
3 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./dist/extensions/config.js"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

🎛️ Pi Config

Config management building block for Pi.

This package provides tools and utilities that can be used to make your Pi stuff easier to configure.

🍿 Usage

  • ⚠️ This package is for Pi. It reads Pi's config files and registers a Pi extension; it's not a general-purpose config loader.
  • If you are wondering why I built this, go to the 📝 Motivation section.

📦 Installation

You can use this package as an extension, which provides tools and commands for the agent, or as a library to read config from your own extension, tool, or skill.

As an extension

pi install npm:@homer0/pi-config

This will expose the following tools and commands to your Pi agent:

Name What it does
get_config Lets the agent read the resolved config (secret-looking values are redacted).
set_config Lets the agent override one config value for the current session.
/config Opens a TUI window to see the resolved config and edit the session override.
/config-reset Clears the session override.

⚠️: When using get_config, secret-looking values will be redacted in the output. To access those, you'll have to get them using the library functions like getConfigValue or queryConfig.

As a library

pnpm add @homer0/pi-config

@earendil-works/pi-coding-agent and @earendil-works/pi-tui are peer dependencies: Pi provides them to your extension, so don't bundle them.

Installing it as a dependency will give you access to an ESM module with the following functions:

Function What it does
getConfig Reads the full resolved config.
getConfigValue Reads a single config value by its path.
queryConfig Queries the config using a structured query.
resolvePiPath Resolves a pi:, agent: or profile:<name>: path (and, by default, absolute and relative ones).

🧙 Configs

File locations

The config files can be written in JSON or YAML format, and the way the package will be read and merged follows a specific order, with later files overriding earlier ones. It will start in the global Pi directory and then look for project-specific config files (<project>/.pi/):

~/<agent>/config.{json,yml,yaml}
~/<agent>/config.local.{json,yml,yaml}
<project>/.pi/config.{json,yml,yaml}
<project>/.pi/config.local.{json,yml,yaml}

The <agent> directory is given by Pi's getAgentDir() function, so it doesn't matter what you name it or where it is located; Pi will resolve it correctly.

The idea with the .local.{json,yml,yaml} files is that they are meant for local overrides that should not be committed to version control.

Merging

Each file is merged on top of the previous ones. Objects are deep-merged, and primitive values replace the previous value.

Arrays are merged by index, not replaced: the items of a later file take the place of the ones in the same position, but the extra items of an earlier array stay. For example, merging [1, 2] with [3] gives [3, 2], so a later file can't make an array shorter.

Namespaces

Pi's config is organized into namespaces, each corresponding to a different kind of integration. The main namespaces are:

Name. Description
pi Something that would affect the overall behavior of Pi.
skills.<name> Configuration for a specific skill.
tools.<name> Configuration for a specific tool.
extensions.<name> Configuration for a specific extension.

For example:

pi:
  setting: value
skills:
  commit:
    runChecks: true
tools:
  bash:
    version: '5.1'
extensions:
  myExtension:
    enabled: true

Documenting your config

The schema of a config always belongs to whatever consumes it: a skill, tool, or extension that uses config should document its own keys. This package only defines the files, how they are merged, and how to read them.

When you document yours, include:

  • The config path, for example skills.commit.runChecks.
  • The type of the value and what happens when it's not set.
  • Whether it's safe to set globally, it only makes sense per project, or it should only be local.
  • An example.
  • How invalid values are handled.

Extending configs

Config files can extend other config files using the extends key. This allows you to build on top of a base configuration and override specific values as needed. For example:

extends: ../base-config.yaml
pi:
  setting: overriddenValue

The path for extends can be...

  1. A relative path: ../base-config.yaml
  2. An absolute path: /path/to/base-config.yaml
  3. A prefixed path: 3.a. pi: refers to a config in the pi directory: pi:base-config.yaml 3.b. agent: refers to a config in the agent directory: agent:./some/base-config.yaml 3.c. profile:<name>: refers to a config inside a specific profile: profile:dev:base-config.yaml
  • pi: and agent: may map to the same directory or not, it depends on your Pi setup.
  • profile:<name>: is used when you have my pi-profiles extension.

A few more details:

  • The base file is loaded first and the file that extends it is merged on top, so the current file always wins.
  • The extends key is removed from the resolved config.
  • Chains work: a base file can extend another one. Circular references are detected and logged as warnings, without breaking the loading of the config.

Resolving paths

If your code needs to accept a path from a config (for example, a file to load) and you only want the prefixed forms, use resolvePiPath, the same function extends uses, with { prefixedOnly: true }. With it, absolute and relative paths are not accepted:

import { resolvePiPath } from '@homer0/pi-config';

resolvePiPath('agent:secrets/keychain.ts', { prefixedOnly: true }); // <agent dir>/secrets/keychain.ts
resolvePiPath('profile:work:tools/lint.ts', { prefixedOnly: true }); // <agent dir>/profiles/work/tools/lint.ts
resolvePiPath('/etc/keychain.ts', { prefixedOnly: true }); // throws: no prefix
resolvePiPath('agent:', { prefixedOnly: true }); // throws: empty path
resolvePiPath('../base.yaml', '/project/.pi'); // /project/base.yaml, the default (what `extends` does)

It throws an error, that includes the value, when the path doesn't start with pi:, agent: or profile:<name>:, or when the path after the prefix is empty.

It only resolves. It doesn't check that the result stays inside the directory of the prefix (agent:../x.ts is x.ts in the parent of the agent directory), that the file exists or that it's safe to use. If the path may come from a config you don't control, apply those restrictions in your code.

Session override

On top of the files, there's one more layer: the session override. It only lives in memory, for the current Pi process, and it has the highest precedence:

~/<agent>/config.{json,yml,yaml}
~/<agent>/config.local.{json,yml,yaml}
<project>/.pi/config.{json,yml,yaml}
<project>/.pi/config.local.{json,yml,yaml}
session override (in-memory, if set)

getConfig, getConfigValue, queryConfig, and the get_config tool apply it automatically, so your code doesn't need to know it exists. You set it with the set_config tool or the /config window, and you clear it with /config-reset or by emptying the editor in the window.

The override is stored on globalThis (as __piConfigSessionOverride) and not in a module variable. Pi loads each extension with its own copy of the modules it imports, so a module variable would only be visible to the code that shares the copy that wrote it. With globalThis, every extension in the process sees the same override, whichever copy of this package it uses.

Limitations:

  • Process-only, in-memory. It's never written to a config file, and it's lost when the process exits (restart, new session, etc.).
  • Stale reads. Only reads that happen after the override is set will reflect it. Anything that already read or cached the config earlier in the same process won't pick it up retroactively.
  • Whole-document override, not per-key unset. Setting a path only replaces its value; there's no way to take a single path back to its file-based value. Clear the override entirely to go back to file-only config.
  • Not schema-validated when set. Neither the /config editor nor set_config validate the shape of the override against a consumer's schema. A badly-shaped override only shows up later, as an error, if a library consumer calls getConfigValue with a Zod schema for the affected path. queryConfig and get_config never validate.

Change event

When the session override changes, the extension emits an event on Pi's shared event bus (pi.events), so other extensions can drop what they cached from the config:

Channel Payload
pi-config:session-override-changed { type: 'set' | 'cleared' }
  • set: the override was set or replaced. It's emitted after set_config and after a non-empty mapping is applied in the /config window.
  • cleared: the override was removed. It's emitted after /config-reset or after emptying the editor in the window, only when there was an override to clear; if there was nothing to clear, nothing is emitted.
  • There's one event per change, emitted after the new override is in place, so reading the config from a listener already returns the new value.
// The channel has its own light entry point, so importing it doesn't load the config readers.
import {
  SESSION_OVERRIDE_CHANGED_CHANNEL,
  type SessionOverrideChange,
} from '@homer0/pi-config/events';

pi.events.on(SESSION_OVERRIDE_CHANGED_CHANNEL, (change) => {
  const { type } = change as SessionOverrideChange;
  // Invalidate whatever you cached from the config.
});

⚙️ Examples

This section will focus on the examples as a library and not the tools provides to the agent, since that will depend on how you would tell the agent to use them through AGENTS.md and/or SKILL.md files.

Reading config

import { getConfig, getConfigValue, queryConfig } from '@homer0/pi-config';

const config = await getConfig(); // the full resolved config
const runChecks = await getConfigValue('skills.commit.runChecks'); // a single path
const bash = await queryConfig({ scope: 'tool', name: 'bash' }); // a structured query

By default, the project config is looked up from process.cwd(). If your code works on another directory, send it as cwd:

const config = await getConfig({ cwd: '/path/to/project' });

Validating your config

getConfig and getConfigValue accept a Zod schema. If the config doesn't exist, you get the usual missing-config result; if it exists and the schema doesn't accept it, they throw a ConfigValidationError:

import { getConfigValue } from '@homer0/pi-config';
import { z } from 'zod';

const commitConfigSchema = z.object({
  runChecks: z.boolean().optional(),
});

const commitConfig = await getConfigValue('skills.commit', {
  schema: commitConfigSchema,
});

queryConfig doesn't accept a schema: it's the structured query the get_config tool is built on.

Typing your config

To get the type of a value from its path, extend the interfaces the package exports with module augmentation. There's one for each namespace: PiConfig, SkillsConfig, ToolsConfig, and ExtensionsConfig:

declare module '@homer0/pi-config' {
  interface SkillsConfig {
    commit?: {
      runChecks?: boolean;
    };
  }
}

With that, getConfigValue infers the type of the value from the path:

const commit = await getConfigValue('skills.commit'); // { runChecks?: boolean } | undefined

The path itself is not checked: any string is accepted, and the type of the value is unknown when it can't be resolved. If you pass a schema, its type is used instead.

🧰 Tools and commands

These are the tools and commands you get when you install the package as an extension. The tools are for the agent: they are meant for when a tool, skill, or extension tells it (through its AGENTS.md or SKILL.md) to read or override config. /config-reset is a command for you, and the /config window has its own section: TUI.

get_config

Reads the resolved config: the files, plus the session override. Its parameters are:

Parameter Description
path A dot-path to read, like skills.commit.runChecks.
scope What to read: all, a whole namespace (pi, skills, tools, extensions), or a single integration (skill, tool, extension, which also need a name).
name The name of the skill, tool, or extension, for the skill, tool, and extension scopes.

The agent should use the narrowest query that answers its question:

{ "path": "skills.commit.runChecks" }
{ "scope": "pi" }
{ "scope": "skill", "name": "commit" }
{ "scope": "tool", "name": "bash" }
{ "scope": "extension", "name": "myExtension" }
  • Its prompt guidelines tell the agent to call it only when a tool, skill, or extension explicitly calls for config, and to treat missing config as no preference, not as an error.
  • Secret-looking values are redacted in the result. Use the library functions if you need the raw values.

set_config

Overrides a single config value for the current session. Its parameters are:

Parameter Description
path The dot-path to override, like extensions.myExtension.enabled.
value The value to set at that path. The rest of the override stays untouched.
  • It changes one path at a time, into the existing session override, and it replies with Session override set: <path> = <value>.
  • Its prompt guidelines tell the agent to call it only when the user explicitly asks for an override, never speculatively or to work around an error.
  • It can't clear the override or remove a path from it. Clearing is done with /config-reset or from the /config window.

/config-reset

Clears the session override right away, without opening a window. It's how you undo set_config, which can only set values, and it's also the quick alternative to emptying the editor in /config. It tells you if there was an override to clear: Session config override cleared. or No session override was set.

🚀 TUI

You open it with the /config command. It's a floating window titled "Config", with two tabs. Use Tab and Shift+Tab to switch between them, and Esc to close the window; closing it doesn't discard anything you already applied.

Effective config shows the resolved config as syntax-highlighted YAML. If it's larger than the window, you can scroll with ↑, ↓, PageUp, PageDown, Home, and End, and the footer shows which lines you are looking at. Press t to switch between the config coming only from the files and the effective config, which includes the session override. The status line always tells you what you are looking at:

Status line What it means
No session override set There's no override.
Session override set — hidden (t to show) There's an override, but you are looking at files only.
Session override set — shown (t to hide) There's an override, and it's applied to what you see.

Override is a YAML editor, prefilled with the current session override (empty if there's none). Press Enter to submit it:

  • A non-empty YAML mapping becomes the new override, and the window says Session override applied.
  • Empty content, or YAML that parses to null, clears the override: Session override cleared.
  • Anything else (invalid YAML, or a value that is not a mapping, like a string or a number) shows an inline error, like Invalid YAML: ..., and the previous override stays as it was.

To add a new line without submitting, use Shift+Enter or Ctrl+J.

🤘 Development

As this project is part of a monorepo, some of the tooling, like lint-staged and husky, is installed in the root's package.json.

Tasks

Task Description
build Builds the package.
lint Lints the package.
lint:file Lints the files you send to it.
test Runs the unit tests.
types:check Validates the TypeScript types.

📝 Motivation

When I started working with Pi, I saw a couple of things that I didn't like:

  • Changing the model made a change in my settings.json
  • I couldn't have a settings.json in the project I was working on for project-specific settings.
  • I couldn't have a settings.local.json for local overrides, and I spent half of my day with a work laptop and the other half with a personal laptop.

Then I started building stuff for Pi, and realized that I wanted things to be configurable without having to mess with the settings.json, owned by the agent.

So I built this, and it's like one of my core building blocks for all the things I create for Pi.