npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@graelo/pi-ext-config

v0.2.0

Published

Shared config-file resolution for pi extensions: project-local before global, with a first-match, shallow-merge or deep-merge strategy

Readme

@graelo/pi-ext-config

Shared config-file resolution for pi extensions.

Every extension that uses this reads config.json from the same two places, in this order:

  1. Project — <repo-root>/.pi/extensions/<extension-id>/config.json, where the repo root is found by walking up from the current directory to the nearest .git
  2. Global — <agent-dir>/extensions/<extension-id>/config.json

What happens when both exist is yours to choose, per extension:

| strategy | behaviour | | --- | --- | | "first-match" (default) | Only the project file is read. It replaces the global one, so it has to be complete. Which file is in effect is always obvious. | | "shallow-merge" | The project file is layered over the global one by top-level key, so it can override one setting and inherit the rest. A nested object replaces its counterpart wholesale. | | "deep-merge" | As above, but recursive: nested objects are merged key by key at every level. |

Defaults always sit underneath whatever is read.

Install

npm install @graelo/pi-ext-config

@earendil-works/pi-coding-agent is a peer dependency — the same one your extension already declares.

Use

import { loadConfig } from "@graelo/pi-ext-config";

interface MyConfig {
  url: string;
  timeoutMs: number;
}

const DEFAULTS: MyConfig = { url: "http://localhost:8080", timeoutMs: 30000 };

const { config, diagnostics } = loadConfig("pi-my-extension", DEFAULTS, {
  cwd: ctx.cwd,
});

for (const problem of diagnostics) {
  if (ctx.hasUI) ctx.ui.notify(problem, "warning");
  else console.warn(problem);
}

To layer a project config over the global one instead:

const { config } = loadConfig("pi-my-extension", DEFAULTS, {
  cwd: ctx.cwd,
  strategy: "shallow-merge", // or "deep-merge" for nested config
});

Pass ctx.cwd when you have it. Without it the repo root is resolved from process.cwd(), which is usually right but not always the directory the user means. Set includeProject: false when an extension must use only the global config tier.

API

loadConfig<T>(extensionId, defaults, options?): LoadedConfig<T>

Reads config from disk, applied over defaults.

interface LoadedConfig<T> {
  config: T;              // defaults with every contributing file applied over them
  sources: string[];      // files that contributed, lowest priority first
  candidates: string[];   // every candidate path, in priority order
  diagnostics: string[];  // files that existed but could not be used
}

sources is empty when nothing readable was found, and holds at most one entry under "first-match". Its last entry is the file that had the final say, under any strategy — that's the one to name in a "loaded config from…" line.

A file that exists but holds malformed JSON (or something that isn't a JSON object) is reported in diagnostics and skipped, falling through to the next location — a stray project config should not strand an extension with no configuration at all. This holds under every strategy.

Nothing is written to the console: reporting is the extension's call, since only it knows whether that means console.warn or ctx.ui.notify. Prefer ctx.ui.notify whenever ctx.hasUI — pi loads extensions in-process and does not capture their output, so a console.* call in TUI mode writes straight onto the terminal the renderer is drawing on. Plain console is right in print (-p) and JSON modes, where stdout really is the output.

A diagnostic distinguishes a file that could not be read from one whose JSON could not be parsed; both are worth showing verbatim, since they send the user to different places.

Choosing a strategy

"first-match" suits config that is small or whose keys travel together — a project file is then a deliberate, complete statement, and there is never any doubt about which file is live. The two merge strategies suit config with several independent knobs, where a repo wants to change one and inherit the rest.

Between them, the question is what a nested object means in your config. For a shape like { phoenix: { endpoint, project } }, setting phoenix.project in a project file drops the global phoenix.endpoint under "shallow-merge", and keeps it under "deep-merge". Neither is wrong: pick "deep-merge" when nested keys are independent knobs like any other, and "shallow-merge" when a block is a cohesive unit — a credentials pair, say, where inheriting half from one file and half from another is worse than replacing the whole thing.

Both merge strategies share one caveat: arrays are replaced, never concatenated. If your config carries a list, the project file's list wins entirely.

Under every strategy, the returned config shares nothing with the defaults you passed in, all the way down — mutating it cannot leak into the module-level constant your extension keeps them in.

That isolation is a structuredClone of defaults, so they must be structured-cloneable: JSON-shaped values, plus Date, Map, Set and friends. A function throws, and a class instance comes back as a plain object with its prototype gone. Neither belongs in something that mirrors a config.json.

getConfigPaths(extensionId, options?): string[]

Candidate paths in priority order, whether or not they exist. Useful for an error message that tells the user where a config file could go.

The project path is omitted entirely when the working directory is not inside a git repository — a bare ./.pi/extensions/... relative to wherever the agent happens to have been started is not something anyone means to configure. It is also omitted when options.includeProject is false, leaving only the global candidate.

resolveConfigPath(extensionId, options?): string | null

The highest-priority config file that exists, or null when none do. Only checks existence — a file holding malformed JSON is still returned.

findGitRoot(startPath): string | null

Walks up from startPath to the enclosing repository root, or null if there isn't one.

.git is a directory in a normal clone but a file in worktrees and submodules, so mere existence marks the root. Never add an isDirectory() check here — it breaks both.

ConfigLocationOptions

interface ConfigLocationOptions {
  cwd?: string;             // where to start looking for the repo root; default process.cwd()
  agentDir?: string;        // overrides the agent directory; intended for tests
  includeProject?: boolean; // include the project tier; default true
}

interface LoadConfigOptions extends ConfigLocationOptions {
  strategy?: "first-match" | "shallow-merge" | "deep-merge"; // default "first-match"
}

includeProject defaults to true; set it to false to make getConfigPaths(), resolveConfigPath(), and loadConfig() use only the global config tier.

agentDir defaults to pi's own getAgentDir(), which already honours PI_CODING_AGENT_DIR and expands a leading ~. Do not re-implement that chain: pi derives the variable name from its own branding, so a hand-rolled process.env.PI_CODING_AGENT_DIR silently reads the wrong variable under a rebranded build.

Design notes

Decisions this library settles once, which the extensions previously each answered differently:

  • Global tier — always getAgentDir(), never a hand-rolled env chain.
  • No repository — skip the project tier rather than falling back to cwd.
  • Malformed project file — fall through to global with a diagnostic, rather than giving up.

Combining files is deliberately not one of them: all three strategies are defensible, they suit different config shapes, and the choice belongs to whoever wrote the extension. What matters is that the location and detection rules are identical either way.

License

MIT