refactor(coding-agent): drop project user config scope

This commit is contained in:
Armin Ronacher
2026-06-02 23:28:50 +02:00
Unverified
parent 1edaf47838
commit bc8c65c919
27 changed files with 292 additions and 690 deletions
+12 -13
View File
@@ -192,7 +192,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/export [file]` | Export session to HTML file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
| `/trust [yes|no|reset]` | Configure whether `.pi` and `.pi.user` are trusted for this working directory |
| `/trust [yes|no|reset]` | Configure whether `.pi` is trusted for this working directory |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |
@@ -285,13 +285,12 @@ Use `/settings` to modify common options, or edit JSON files directly:
|----------|-------|
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (overrides global) |
| `.pi.user/settings.json` | Project-local user overrides (ignored by Git when Pi creates it) |
See [docs/settings.md](docs/settings.md) for all options.
### Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
### Telemetry and update checks
@@ -317,7 +316,7 @@ Disable context file loading with `--no-context-files` (or `-nc`).
### System Prompt
Replace the default system prompt with `.pi.user/SYSTEM.md` (project-local user), `.pi/SYSTEM.md` (project), or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md` in the same locations.
Replace the default system prompt with `.pi/SYSTEM.md` (project) or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md`.
---
@@ -333,7 +332,7 @@ Review this code for bugs, security issues, and performance problems.
Focus on: {{focus}}
```
Place in `~/.pi/agent/prompts/`, `.pi/prompts/`, `.pi.user/prompts/`, or a [pi package](#pi-packages) to share with others. See [docs/prompt-templates.md](docs/prompt-templates.md).
Place in `~/.pi/agent/prompts/`, `.pi/prompts/`, or a [pi package](#pi-packages) to share with others. See [docs/prompt-templates.md](docs/prompt-templates.md).
### Skills
@@ -349,7 +348,7 @@ Use this skill when the user asks about X.
2. Then that
```
Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, `.pi.user/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md).
Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md).
### Extensions
@@ -381,13 +380,13 @@ The default export can also be `async`. pi waits for async extension factories b
- Games while waiting (yes, Doom runs)
- ...anything you can dream up
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, `.pi.user/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).
### Themes
Built-in: `dark`, `light`. Themes hot-reload: modify the active theme file and pi immediately applies changes.
Place in `~/.pi/agent/themes/`, `.pi/themes/`, `.pi.user/themes/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md).
Place in `~/.pi/agent/themes/`, `.pi/themes/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md).
### Pi Packages
@@ -417,7 +416,7 @@ pi update npm:@foo/pi-tools # update one package
pi config # enable/disable extensions, skills, prompts, themes
```
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`), or `-l -u` for ignored project-local user installs (`.pi.user/git/`, `.pi.user/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Create a package by adding a `pi` key to `package.json`:
@@ -505,9 +504,9 @@ pi [options] [@files...] [messages...]
### Package Commands
```bash
pi install <source> [-l] [-u] # Install package, -l for project-local, -u for .pi.user with -l
pi remove <source> [-l] [-u] # Remove package
pi uninstall <source> [-l] [-u] # Alias for remove
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
pi update [source|self|pi] # Update pi and packages (skips pinned packages)
pi update --extensions # Update packages only
pi update --self # Update pi only
@@ -590,7 +589,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup |
| `-f`, `--force` | Force loading project `.pi` and `.pi.user` config regardless of trust |
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+1 -3
View File
@@ -4,7 +4,7 @@
Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global), `.pi/extensions/` (project-local), or `.pi.user/extensions/` (project-local user) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.
> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.
**Key capabilities:**
- **Custom tools** - Register tools the LLM can call via `pi.registerTool()`
@@ -117,8 +117,6 @@ Extensions are auto-discovered from:
| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
| `.pi/extensions/*.ts` | Project-local |
| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
| `.pi.user/extensions/*.ts` | Project-local user |
| `.pi.user/extensions/*/index.ts` | Project-local user (subdirectory) |
Additional paths via `settings.json`:
+4 -4
View File
@@ -38,7 +38,7 @@ pi update --extension npm:@foo/bar
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Use `-l -u` to write to ignored project-local user settings (`.pi.user/settings.json`). Project settings can be shared with your team, and pi installs any missing packages automatically on startup.
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup.
To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:
@@ -60,7 +60,7 @@ npm:pkg
- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`).
- User installs go under `~/.pi/agent/npm/`.
- Project installs go under `.pi/npm/`; project-local user installs go under `.pi.user/npm/`.
- Project installs go under `.pi/npm/`.
- Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`.
Example:
@@ -87,7 +87,7 @@ ssh://git@github.com/user/repo@v1
- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global), `.pi/git/<host>/<path>` (project), or `.pi.user/git/<host>/<path>` (project-local user).
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
**SSH examples:**
@@ -219,7 +219,7 @@ Use `pi config` to enable or disable extensions, skills, prompt templates, and t
## Scope and Deduplication
Packages can appear in global, project, and project-local user settings. If the same package appears in more than one scope, `.pi.user` wins over `.pi`, and project scopes win over global. Identity is determined by:
Packages can appear in both global and project settings. If the same package appears in both, the project entry wins. Identity is determined by:
- npm: package name
- git: repository URL without ref
@@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam
Pi loads prompt templates from:
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`, `.pi.user/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)
+5 -11
View File
@@ -1,18 +1,17 @@
# Settings
Pi uses JSON settings files with project settings overriding global settings and `.pi.user` overriding shared project settings.
Pi uses JSON settings files with project settings overriding global settings.
| Location | Scope |
|----------|-------|
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (current directory) |
| `.pi.user/settings.json` | Project-local user overrides (ignored by Git when Pi creates it) |
Edit directly or use `/settings` for common options.
## Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
## All Settings
@@ -198,7 +197,7 @@ When multiple sources specify a session directory, precedence is `--session-dir`
These settings define where to load extensions, skills, prompts, and themes from.
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`; paths in `.pi.user/settings.json` resolve relative to `.pi.user`. Absolute paths and `~` are supported.
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
@@ -264,7 +263,7 @@ See [packages.md](packages.md) for package management details.
## Project Overrides
Project settings (`.pi/settings.json`) override global settings. Project-local user settings (`.pi.user/settings.json`) override both. Nested objects are merged:
Project settings (`.pi/settings.json`) override global settings. Nested objects are merged:
```json
// ~/.pi/agent/settings.json (global)
@@ -278,14 +277,9 @@ Project settings (`.pi/settings.json`) override global settings. Project-local u
"compaction": { "reserveTokens": 8192 }
}
// .pi.user/settings.json (project-local user)
{
"theme": "light"
}
// Result
{
"theme": "light",
"theme": "dark",
"compaction": { "enabled": true, "reserveTokens": 8192 }
}
```
+1 -2
View File
@@ -28,14 +28,13 @@ Pi loads skills from:
- `~/.agents/skills/`
- Project:
- `.pi/skills/`
- `.pi.user/skills/`
- `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)
- Packages: `skills/` directories or `pi.skills` entries in `package.json`
- Settings: `skills` array with files or directories
- CLI: `--skill <path>` (repeatable, additive even with `--no-skills`)
Discovery rules:
- In `~/.pi/agent/skills/`, `.pi/skills/`, and `.pi.user/skills/`, direct root `.md` files are discovered as individual skills
- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills
- In all skill locations, directories containing `SKILL.md` are discovered recursively
- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored
+1 -1
View File
@@ -20,7 +20,7 @@ Pi loads themes from:
- Built-in: `dark`, `light`
- Global: `~/.pi/agent/themes/*.json`
- Project: `.pi/themes/*.json`, `.pi.user/themes/*.json`
- Project: `.pi/themes/*.json`
- Packages: `themes/` directories or `pi.themes` entries in `package.json`
- Settings: `themes` array with files or directories
- CLI: `--theme <path>` (repeatable)
+6 -7
View File
@@ -51,7 +51,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/export [file]` | Export session to HTML |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files |
| `/trust [yes|no|reset]` | Configure whether `.pi` and `.pi.user` are trusted for this working directory |
| `/trust [yes|no|reset]` | Configure whether `.pi` is trusted for this working directory |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |
@@ -106,7 +106,6 @@ Use context files for project conventions, commands, safety rules, and preferenc
Replace the default system prompt with:
- `.pi.user/SYSTEM.md` for project-local user overrides
- `.pi/SYSTEM.md` for a project
- `~/.pi/agent/SYSTEM.md` globally
@@ -114,7 +113,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in tho
### Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
## Exporting and Sharing Sessions
@@ -133,9 +132,9 @@ pi [options] [@files...] [messages...]
### Package Commands
```bash
pi install <source> [-l] [-u] # Install package, -l for project-local, -u for .pi.user with -l
pi remove <source> [-l] [-u] # Remove package
pi uninstall <source> [-l] [-u] # Alias for remove
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
pi update [source|self|pi] # Update pi and packages; reconcile pinned git refs
pi update --extensions # Update packages only; reconcile pinned git refs
pi update --self # Update pi only
@@ -225,7 +224,7 @@ pi --no-extensions -e ./my-extension.ts
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup |
| `-f`, `--force` | Force loading project `.pi` and `.pi.user` config regardless of trust |
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+4 -4
View File
@@ -224,9 +224,9 @@ ${chalk.bold("Usage:")}
${APP_NAME} [options] [@files...] [messages...]
${chalk.bold("Commands:")}
${APP_NAME} install <source> [-l] [-u] Install extension source and add to settings
${APP_NAME} remove <source> [-l] [-u] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] [-u] Alias for remove
${APP_NAME} install <source> [-l] Install extension source and add to settings
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list [--force] List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
@@ -269,7 +269,7 @@ ${chalk.bold("Options:")}
--export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search)
--verbose Force verbose startup (overrides quietStartup setting)
--force, -f Force loading project .pi and .pi.user config
--force, -f Force loading project .pi config
--offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help
--version, -v Show version number
-1
View File
@@ -465,7 +465,6 @@ export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent
export const APP_NAME: string = piConfigName || "pi";
export const APP_TITLE: string = piConfigName ? APP_NAME : "π";
export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi";
export const PROJECT_USER_CONFIG_DIR_NAME = `${CONFIG_DIR_NAME}.user`;
export const VERSION: string = pkg.version || "0.0.0";
// e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR
+177 -222
View File
@@ -27,12 +27,11 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import { ensureIgnoredProjectUserConfigDir } from "./project-user-config.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
const NETWORK_TIMEOUT_MS = 10000;
@@ -45,12 +44,9 @@ function isOfflineModeEnabled(): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
export type SettingsSourceScope = "user" | "project" | "projectUser";
export type ResourceMetadataScope = SettingsSourceScope | "temporary";
export interface PathMetadata {
source: string;
scope: ResourceMetadataScope;
scope: SourceScope;
origin: "package" | "top-level";
baseDir?: string;
}
@@ -83,32 +79,32 @@ export interface PackageUpdate {
source: string;
displayName: string;
type: "npm" | "git";
scope: InstalledSourceScope;
scope: Exclude<SourceScope, "temporary">;
}
export interface ConfiguredPackage {
source: string;
scope: InstalledSourceScope;
scope: "user" | "project";
filtered: boolean;
installedPath?: string;
}
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void>;
installAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<boolean>;
install(source: string, options?: { local?: boolean }): Promise<void>;
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
listConfiguredPackages(): ConfiguredPackage[];
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
addSourceToSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean;
removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean;
addSourceToSettings(source: string, options?: { local?: boolean }): boolean;
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean;
setProgressCallback(callback: ProgressCallback | undefined): void;
getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined;
getInstalledPath(source: string, scope: "user" | "project"): string | undefined;
}
interface PackageManagerOptions {
@@ -117,7 +113,7 @@ interface PackageManagerOptions {
settingsManager: SettingsManager;
}
type SourceScope = SettingsSourceScope | "temporary";
type SourceScope = "user" | "project" | "temporary";
type NpmSource = {
type: "npm";
@@ -133,7 +129,7 @@ type LocalSource = {
type ParsedSource = NpmSource | GitSource | LocalSource;
export type InstalledSourceScope = SettingsSourceScope;
type InstalledSourceScope = Exclude<SourceScope, "temporary">;
interface ConfiguredUpdateSource {
source: string;
@@ -168,17 +164,15 @@ interface ResourceAccumulator {
* name-collision resolution ("first wins") produces the correct outcome.
*
* Precedence (highest to lowest):
* 0 .pi.user + settings entry
* 1 .pi.user + auto-discovered
* 2 .pi + settings entry
* 3 .pi + auto-discovered
* 4 user + settings entry
* 5 user + auto-discovered
* 6 package resource
* 0 project + settings entry (source: "local", scope: "project")
* 1 project + auto-discovered (source: "auto", scope: "project")
* 2 user + settings entry (source: "local", scope: "user")
* 3 user + auto-discovered (source: "auto", scope: "user")
* 4 package resource (origin: "package")
*/
function resourcePrecedenceRank(m: PathMetadata): number {
if (m.origin === "package") return 6;
const scopeBase = m.scope === "projectUser" ? 0 : m.scope === "project" ? 2 : m.scope === "user" ? 4 : 6;
if (m.origin === "package") return 4;
const scopeBase = m.scope === "project" ? 0 : 2;
return scopeBase + (m.source === "local" ? 0 : 1);
}
@@ -778,36 +772,10 @@ export class DefaultPackageManager implements PackageManager {
this.progressCallback = callback;
}
private getScopeForOptions(options?: { local?: boolean; localUser?: boolean }): InstalledSourceScope {
if (options?.localUser) {
return "projectUser";
}
return options?.local ? "project" : "user";
}
private getSettingsForScope(scope: InstalledSourceScope) {
if (scope === "projectUser") {
return this.settingsManager.getProjectUserSettings();
}
if (scope === "project") {
return this.settingsManager.getProjectSettings();
}
return this.settingsManager.getGlobalSettings();
}
private setPackagesForScope(scope: InstalledSourceScope, packages: PackageSource[]): void {
if (scope === "projectUser") {
this.settingsManager.setProjectUserPackages(packages);
} else if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
} else {
this.settingsManager.setPackages(packages);
}
}
addSourceToSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean {
const scope = this.getScopeForOptions(options);
const currentSettings = this.getSettingsForScope(scope);
addSourceToSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
@@ -819,28 +787,41 @@ export class DefaultPackageManager implements PackageManager {
const nextPackages = [...currentPackages];
nextPackages[matchIndex] =
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
this.setPackagesForScope(scope, nextPackages);
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
const nextPackages = [...currentPackages, normalizedSource];
this.setPackagesForScope(scope, nextPackages);
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean {
const scope = this.getScopeForOptions(options);
const currentSettings = this.getSettingsForScope(scope);
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope));
const changed = nextPackages.length !== currentPackages.length;
if (!changed) {
return false;
}
this.setPackagesForScope(scope, nextPackages);
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined {
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope);
@@ -882,42 +863,39 @@ export class DefaultPackageManager implements PackageManager {
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const projectSettings = this.settingsManager.getProjectSettings();
// Collect all packages with scope (project-local user first so it wins collisions)
// Collect all packages with scope (project first so cwd resources win collisions)
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
allPackages.push({ pkg, scope: layer.scope });
}
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
// Dedupe: earlier scopes win for the same package identity.
// Dedupe: project scope wins over global for same package identity
const packageSources = this.dedupePackages(allPackages);
await this.resolvePackageSources(packageSources, accumulator, onMissing);
const globalBaseDir = this.agentDir;
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
for (const layer of projectLayers) {
const projectEntries = (layer.settings[resourceType] ?? []) as string[];
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: layer.scope,
origin: "top-level",
},
this.getBaseDirForScope(layer.scope),
);
}
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: "project",
origin: "top-level",
},
projectBaseDir,
);
this.resolveLocalEntries(
globalEntries,
resourceType,
@@ -931,7 +909,7 @@ export class DefaultPackageManager implements PackageManager {
);
}
this.addAutoDiscoveredResources(accumulator, globalSettings, projectLayers, globalBaseDir);
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
return this.toResolvedPaths(accumulator);
}
@@ -949,7 +927,7 @@ export class DefaultPackageManager implements PackageManager {
listConfiguredPackages(): ConfiguredPackage[] {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const projectSettings = this.settingsManager.getProjectSettings();
const configuredPackages: ConfiguredPackage[] = [];
for (const pkg of globalSettings.packages ?? []) {
@@ -962,24 +940,22 @@ export class DefaultPackageManager implements PackageManager {
});
}
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: layer.scope,
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, layer.scope),
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
}
return configuredPackages;
}
async install(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = this.getScopeForOptions(options);
const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
@@ -1000,14 +976,14 @@ export class DefaultPackageManager implements PackageManager {
});
}
async installAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = this.getScopeForOptions(options);
const scope: SourceScope = options?.local ? "project" : "user";
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
@@ -1024,14 +1000,14 @@ export class DefaultPackageManager implements PackageManager {
});
}
async removeAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<boolean> {
async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
await this.remove(source, options);
return this.removeSourceFromSettings(source, options);
}
async update(source?: string): Promise<void> {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const projectSettings = this.settingsManager.getProjectSettings();
const identity = source ? this.getPackageIdentity(source) : undefined;
let matched = false;
const updateSources: ConfiguredUpdateSource[] = [];
@@ -1042,20 +1018,18 @@ export class DefaultPackageManager implements PackageManager {
matched = true;
updateSources.push({ source: sourceStr, scope: "user" });
}
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, layer.scope) !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: layer.scope });
}
for (const pkg of projectSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "project" });
}
if (source && !matched) {
throw new Error(
this.buildNoMatchingPackageMessage(source, [
...(globalSettings.packages ?? []),
...projectLayers.flatMap((layer) => layer.settings.packages ?? []),
...(projectSettings.packages ?? []),
]),
);
}
@@ -1089,21 +1063,25 @@ export class DefaultPackageManager implements PackageManager {
shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope),
}));
const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);
const npmUpdatesByScope = new Map<InstalledSourceScope, NpmUpdateTarget[]>();
const userNpmUpdates: NpmUpdateTarget[] = [];
const projectNpmUpdates: NpmUpdateTarget[] = [];
for (const result of npmCheckResults) {
if (!result.shouldUpdate) {
continue;
}
const updates = npmUpdatesByScope.get(result.entry.scope) ?? [];
updates.push(result.entry);
npmUpdatesByScope.set(result.entry.scope, updates);
if (result.entry.scope === "user") {
userNpmUpdates.push(result.entry);
} else {
projectNpmUpdates.push(result.entry);
}
}
const tasks: Promise<void>[] = [];
for (const [scope, updates] of npmUpdatesByScope.entries()) {
if (updates.length > 0) {
tasks.push(this.updateNpmBatch(updates, scope));
}
if (userNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));
}
if (projectNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
}
if (gitCandidates.length > 0) {
const gitTasks = gitCandidates.map(
@@ -1160,12 +1138,10 @@ export class DefaultPackageManager implements PackageManager {
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const projectSettings = this.settingsManager.getProjectSettings();
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
allPackages.push({ pkg, scope: layer.scope });
}
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
@@ -1173,7 +1149,10 @@ export class DefaultPackageManager implements PackageManager {
const packageSources = this.dedupePackages(allPackages);
const checks = packageSources
.filter((entry): entry is { pkg: PackageSource; scope: InstalledSourceScope } => entry.scope !== "temporary")
.filter(
(entry): entry is { pkg: PackageSource; scope: Exclude<SourceScope, "temporary"> } =>
entry.scope !== "temporary",
)
.map((entry) => async (): Promise<PackageUpdate | undefined> => {
const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const parsed = this.parseSource(source);
@@ -1227,11 +1206,7 @@ export class DefaultPackageManager implements PackageManager {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const parsed = this.parseSource(sourceStr);
const metadata: PathMetadata = {
source: sourceStr,
scope,
origin: "package",
};
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
@@ -1655,8 +1630,8 @@ export class DefaultPackageManager implements PackageManager {
}
/**
* Dedupe packages by identity. Callers pass sources in precedence order, so the
* first occurrence wins.
* Dedupe packages: if same package identity appears in both global and project,
* keep only the project one (project wins).
*/
private dedupePackages(
packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
@@ -1667,10 +1642,15 @@ export class DefaultPackageManager implements PackageManager {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const identity = this.getPackageIdentity(sourceStr, entry.scope);
if (!seen.has(identity)) {
const existing = seen.get(identity);
if (!existing) {
seen.set(identity, entry);
} else if (entry.scope === "project" && existing.scope === "user") {
// Project wins over user
seen.set(identity, entry);
}
// Callers pass sources in precedence order. Keep the first matching identity.
// If existing is project and new is global, keep existing (project)
// If both are same scope, keep first one
}
return Array.from(seen.values());
@@ -1748,9 +1728,6 @@ export class DefaultPackageManager implements PackageManager {
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "projectUser") {
this.ensureProjectUserRootIgnore();
}
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot));
@@ -1769,9 +1746,6 @@ export class DefaultPackageManager implements PackageManager {
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
if (scope === "projectUser") {
this.ensureProjectUserRootIgnore();
}
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
if (source.ref) {
@@ -1907,17 +1881,10 @@ export class DefaultPackageManager implements PackageManager {
}
}
private ensureProjectUserRootIgnore(): void {
ensureIgnoredProjectUserConfigDir(join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME));
}
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
if (temporary) {
return this.getTemporaryDir("npm");
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "npm");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
@@ -1958,9 +1925,6 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
@@ -1988,22 +1952,17 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "temporary") {
return this.getTemporaryDir(`git-${source.host}`, source.path);
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "git", source.host, source.path);
const installRoot = this.getGitInstallRoot(scope);
if (!installRoot) {
throw new Error("Missing git install root");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
}
return join(this.agentDir, "git", source.host, source.path);
return this.resolveManagedPath(installRoot, source.host, source.path);
}
private getGitInstallRoot(scope: SourceScope): string | undefined {
if (scope === "temporary") {
return undefined;
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "git");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git");
}
@@ -2011,17 +1970,24 @@ export class DefaultPackageManager implements PackageManager {
}
private getTemporaryDir(prefix: string, suffix?: string): string {
const root = this.resolveManagedPath(join(tmpdir(), "pi-extensions"), prefix);
const hash = createHash("sha256")
.update(`${prefix}-${suffix ?? ""}`)
.digest("hex")
.slice(0, 8);
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
return this.resolveManagedPath(root, hash, suffix ?? "");
}
private resolveManagedPath(root: string, ...parts: string[]): string {
const resolvedRoot = resolve(root);
const resolvedPath = resolve(resolvedRoot, ...parts);
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {
throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`);
}
return resolvedPath;
}
private getBaseDirForScope(scope: SourceScope): string {
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME);
}
@@ -2241,8 +2207,9 @@ export class DefaultPackageManager implements PackageManager {
private addAutoDiscoveredResources(
accumulator: ResourceAccumulator,
globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>,
projectLayers: ReturnType<SettingsManager["getProjectSettingsLayers"]>,
projectSettings: ReturnType<SettingsManager["getProjectSettings"]>,
globalBaseDir: string,
projectBaseDir: string,
): void {
const userMetadata: PathMetadata = {
source: "auto",
@@ -2250,11 +2217,11 @@ export class DefaultPackageManager implements PackageManager {
origin: "top-level",
baseDir: globalBaseDir,
};
const projectAgentMetadata: PathMetadata = {
const projectMetadata: PathMetadata = {
source: "auto",
scope: "project",
origin: "top-level",
baseDir: join(this.cwd, CONFIG_DIR_NAME),
baseDir: projectBaseDir,
};
const userOverrides = {
@@ -2263,11 +2230,11 @@ export class DefaultPackageManager implements PackageManager {
prompts: (globalSettings.prompts ?? []) as string[],
themes: (globalSettings.themes ?? []) as string[],
};
const projectSkillOverrides = [...projectLayers]
.reverse()
.reduce<string[]>((skills, layer) => layer.settings.skills ?? skills, []);
const projectAgentOverrides = {
skills: projectSkillOverrides,
const projectOverrides = {
extensions: (projectSettings.extensions ?? []) as string[],
skills: (projectSettings.skills ?? []) as string[],
prompts: (projectSettings.prompts ?? []) as string[],
themes: (projectSettings.themes ?? []) as string[],
};
const userDirs = {
@@ -2276,6 +2243,12 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
@@ -2295,73 +2268,55 @@ export class DefaultPackageManager implements PackageManager {
}
};
for (const layer of projectLayers) {
const projectBaseDir = this.getBaseDirForScope(layer.scope);
const projectMetadata: PathMetadata = {
source: "auto",
scope: layer.scope,
origin: "top-level",
baseDir: projectBaseDir,
};
const projectOverrides = {
extensions: (layer.settings.extensions ?? []) as string[],
skills: (layer.settings.skills ?? []) as string[],
prompts: (layer.settings.prompts ?? []) as string[],
themes: (layer.settings.themes ?? []) as string[],
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
// Project extensions from .pi/
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
addResources(
"skills",
collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
projectOverrides.themes,
projectBaseDir,
);
}
// Project skills from .pi/
addResources(
"skills",
collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
// Project skills from .agents/ (each with its own baseDir)
for (const agentsSkillsDir of projectAgentsSkillDirs) {
const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
const agentsMetadata: PathMetadata = {
...projectAgentMetadata,
...projectMetadata,
baseDir: agentsBaseDir,
};
addResources(
"skills",
collectAutoSkillEntries(agentsSkillsDir, "agents"),
agentsMetadata,
projectAgentOverrides.skills,
projectOverrides.skills,
agentsBaseDir,
);
}
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
projectOverrides.themes,
projectBaseDir,
);
// User extensions from ~/.pi/agent/
addResources(
"extensions",
@@ -1,14 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export const PROJECT_USER_GITIGNORE_CONTENT = "*\n.*\n";
export function ensureIgnoredProjectUserConfigDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const ignorePath = join(dir, ".gitignore");
if (!existsSync(ignorePath)) {
writeFileSync(ignorePath, PROJECT_USER_GITIGNORE_CONTENT, "utf-8");
}
}
@@ -1,7 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME } from "../config.ts";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
@@ -652,10 +652,6 @@ export class DefaultResourceLoader implements ResourceLoader {
join(this.agentDir, "extensions"),
];
const projectRoots = [
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "skills"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "prompts"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "themes"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "extensions"),
join(this.cwd, CONFIG_DIR_NAME, "skills"),
join(this.cwd, CONFIG_DIR_NAME, "prompts"),
join(this.cwd, CONFIG_DIR_NAME, "themes"),
@@ -858,13 +854,9 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private discoverSystemPromptFile(): string | undefined {
const projectPaths = this.settingsManager.isProjectConfigTrusted()
? [join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "SYSTEM.md"), join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md")]
: [];
for (const projectPath of projectPaths) {
if (existsSync(projectPath)) {
return projectPath;
}
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (this.settingsManager.isProjectConfigTrusted() && existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "SYSTEM.md");
@@ -876,16 +868,9 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private discoverAppendSystemPromptFile(): string | undefined {
const projectPaths = this.settingsManager.isProjectConfigTrusted()
? [
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "APPEND_SYSTEM.md"),
join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"),
]
: [];
for (const projectPath of projectPaths) {
if (existsSync(projectPath)) {
return projectPath;
}
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (this.settingsManager.isProjectConfigTrusted() && existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
@@ -2,10 +2,9 @@ import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
import { ensureIgnoredProjectUserConfigDir } from "./project-user-config.ts";
export interface CompactionSettings {
enabled?: boolean; // default: true
@@ -158,18 +157,12 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde
return undefined;
}
export type SettingsScope = "global" | "project" | "projectUser";
export type ProjectSettingsScope = "project" | "projectUser";
export type SettingsScope = "global" | "project";
export interface SettingsManagerCreateOptions {
projectConfigTrusted?: boolean;
}
export interface ProjectSettingsLayer {
scope: ProjectSettingsScope;
settings: Settings;
}
export interface SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
setProjectConfigTrusted?(trusted: boolean): void;
@@ -184,7 +177,6 @@ export interface SettingsError {
export class FileSettingsStorage implements SettingsStorage {
private globalSettingsPath: string;
private projectSettingsPath: string;
private projectUserSettingsPath: string;
private projectConfigTrusted: boolean;
constructor(cwd: string, agentDir: string, options: SettingsManagerCreateOptions = {}) {
@@ -192,7 +184,6 @@ export class FileSettingsStorage implements SettingsStorage {
const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
this.projectUserSettingsPath = join(resolvedCwd, PROJECT_USER_CONFIG_DIR_NAME, "settings.json");
this.projectConfigTrusted = options.projectConfigTrusted ?? true;
}
@@ -231,24 +222,13 @@ export class FileSettingsStorage implements SettingsStorage {
throw (lastError as Error) ?? new Error("Failed to acquire settings lock");
}
private getSettingsPath(scope: SettingsScope): string {
switch (scope) {
case "global":
return this.globalSettingsPath;
case "project":
return this.projectSettingsPath;
case "projectUser":
return this.projectUserSettingsPath;
}
}
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) {
if (scope === "project" && !this.projectConfigTrusted) {
fn(undefined);
return;
}
const path = this.getSettingsPath(scope);
const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
const dir = dirname(path);
let release: (() => void) | undefined;
@@ -262,9 +242,7 @@ export class FileSettingsStorage implements SettingsStorage {
const next = fn(current);
if (next !== undefined) {
// Only create directory when we actually need to write
if (scope === "projectUser") {
ensureIgnoredProjectUserConfigDir(dir);
} else if (!existsSync(dir)) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (!release) {
@@ -283,7 +261,6 @@ export class FileSettingsStorage implements SettingsStorage {
export class InMemorySettingsStorage implements SettingsStorage {
private global: string | undefined;
private project: string | undefined;
private projectUser: string | undefined;
private projectConfigTrusted = true;
setProjectConfigTrusted(trusted: boolean): void {
@@ -295,20 +272,18 @@ export class InMemorySettingsStorage implements SettingsStorage {
}
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) {
if (scope === "project" && !this.projectConfigTrusted) {
fn(undefined);
return;
}
const current = scope === "global" ? this.global : scope === "project" ? this.project : this.projectUser;
const current = scope === "global" ? this.global : this.project;
const next = fn(current);
if (next !== undefined) {
if (scope === "global") {
this.global = next;
} else if (scope === "project") {
this.project = next;
} else {
this.projectUser = next;
this.project = next;
}
}
}
@@ -318,18 +293,14 @@ export class SettingsManager {
private storage: SettingsStorage;
private globalSettings: Settings;
private projectSettings: Settings;
private projectUserSettings: Settings;
private settings: Settings;
private projectConfigTrusted: boolean;
private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications
private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session
private modifiedProjectNestedFields = new Map<keyof Settings, Set<string>>(); // Track project nested field modifications
private modifiedProjectUserFields = new Set<keyof Settings>(); // Track .pi.user fields modified during session
private modifiedProjectUserNestedFields = new Map<keyof Settings, Set<string>>(); // Track .pi.user nested field modifications
private globalSettingsLoadError: Error | null = null; // Track if global settings file had parse errors
private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors
private projectUserSettingsLoadError: Error | null = null; // Track if .pi.user settings file had parse errors
private writeQueue: Promise<void> = Promise.resolve();
private errors: SettingsError[];
@@ -337,35 +308,19 @@ export class SettingsManager {
storage: SettingsStorage,
initialGlobal: Settings,
initialProject: Settings,
initialProjectUser: Settings,
globalLoadError: Error | null = null,
projectLoadError: Error | null = null,
projectUserLoadError: Error | null = null,
initialErrors: SettingsError[] = [],
projectConfigTrusted = true,
) {
this.storage = storage;
this.globalSettings = initialGlobal;
this.projectSettings = initialProject;
this.projectUserSettings = initialProjectUser;
this.projectConfigTrusted = projectConfigTrusted;
this.globalSettingsLoadError = globalLoadError;
this.projectSettingsLoadError = projectLoadError;
this.projectUserSettingsLoadError = projectUserLoadError;
this.errors = [...initialErrors];
this.settings = this.mergeAllSettings();
}
private mergeProjectSettings(): Settings {
return deepMergeSettings(this.projectSettings, this.projectUserSettings);
}
private mergeAllSettings(): Settings {
return deepMergeSettings(this.globalSettings, this.mergeProjectSettings());
}
private rebuildSettings(): void {
this.settings = this.mergeAllSettings();
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
}
/** Create a SettingsManager that loads from files */
@@ -383,7 +338,6 @@ export class SettingsManager {
const projectConfigTrusted = storage.isProjectConfigTrusted?.() ?? true;
const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project");
const projectUserLoad = SettingsManager.tryLoadFromStorage(storage, "projectUser");
const initialErrors: SettingsError[] = [];
if (globalLoad.error) {
initialErrors.push({ scope: "global", error: globalLoad.error });
@@ -391,18 +345,13 @@ export class SettingsManager {
if (projectLoad.error) {
initialErrors.push({ scope: "project", error: projectLoad.error });
}
if (projectUserLoad.error) {
initialErrors.push({ scope: "projectUser", error: projectUserLoad.error });
}
return new SettingsManager(
storage,
globalLoad.settings,
projectLoad.settings,
projectUserLoad.settings,
globalLoad.error,
projectLoad.error,
projectUserLoad.error,
initialErrors,
projectConfigTrusted,
);
@@ -511,20 +460,6 @@ export class SettingsManager {
return structuredClone(this.projectSettings);
}
getProjectUserSettings(): Settings {
return structuredClone(this.projectUserSettings);
}
getProjectSettingsLayers(): ProjectSettingsLayer[] {
if (!this.projectConfigTrusted) {
return [];
}
return [
{ scope: "projectUser", settings: structuredClone(this.projectUserSettings) },
{ scope: "project", settings: structuredClone(this.projectSettings) },
];
}
isProjectConfigTrusted(): boolean {
return this.projectConfigTrusted;
}
@@ -534,14 +469,10 @@ export class SettingsManager {
this.storage.setProjectConfigTrusted?.(trusted);
if (!trusted) {
this.projectSettings = {};
this.projectUserSettings = {};
this.projectSettingsLoadError = null;
this.projectUserSettingsLoadError = null;
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
this.rebuildSettings();
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
}
}
@@ -560,8 +491,6 @@ export class SettingsManager {
this.modifiedNestedFields.clear();
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project");
if (!projectLoad.error) {
@@ -572,16 +501,7 @@ export class SettingsManager {
this.recordError("project", projectLoad.error);
}
const projectUserLoad = SettingsManager.tryLoadFromStorage(this.storage, "projectUser");
if (!projectUserLoad.error) {
this.projectUserSettings = projectUserLoad.settings;
this.projectUserSettingsLoadError = null;
} else {
this.projectUserSettingsLoadError = projectUserLoad.error;
this.recordError("projectUser", projectUserLoad.error);
}
this.rebuildSettings();
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
}
/** Apply additional overrides on top of current settings */
@@ -611,17 +531,6 @@ export class SettingsManager {
}
}
/** Mark a .pi.user field as modified during this session */
private markProjectUserModified(field: keyof Settings, nestedKey?: string): void {
this.modifiedProjectUserFields.add(field);
if (nestedKey) {
if (!this.modifiedProjectUserNestedFields.has(field)) {
this.modifiedProjectUserNestedFields.set(field, new Set());
}
this.modifiedProjectUserNestedFields.get(field)!.add(nestedKey);
}
}
private recordError(scope: SettingsScope, error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push({ scope, error: normalizedError });
@@ -633,14 +542,9 @@ export class SettingsManager {
this.modifiedNestedFields.clear();
return;
}
if (scope === "project") {
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
return;
}
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
}
private enqueueWrite(scope: SettingsScope, task: () => void): void {
@@ -694,7 +598,7 @@ export class SettingsManager {
}
private save(): void {
this.rebuildSettings();
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
if (this.globalSettingsLoadError) {
return;
@@ -711,7 +615,7 @@ export class SettingsManager {
private saveProjectSettings(settings: Settings): void {
this.projectSettings = structuredClone(settings);
this.rebuildSettings();
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
if (this.projectSettingsLoadError) {
return;
@@ -725,22 +629,6 @@ export class SettingsManager {
});
}
private saveProjectUserSettings(settings: Settings): void {
this.projectUserSettings = structuredClone(settings);
this.rebuildSettings();
if (this.projectUserSettingsLoadError) {
return;
}
const snapshotProjectUserSettings = structuredClone(this.projectUserSettings);
const modifiedFields = new Set(this.modifiedProjectUserFields);
const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectUserNestedFields);
this.enqueueWrite("projectUser", () => {
this.persistScopedSettings("projectUser", snapshotProjectUserSettings, modifiedFields, modifiedNestedFields);
});
}
async flush(): Promise<void> {
await this.writeQueue;
}
@@ -1017,13 +905,6 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserPackages(packages: PackageSource[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.packages = packages;
this.markProjectUserModified("packages");
this.saveProjectUserSettings(projectUserSettings);
}
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
@@ -1041,13 +922,6 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserExtensionPaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.extensions = paths;
this.markProjectUserModified("extensions");
this.saveProjectUserSettings(projectUserSettings);
}
getSkillPaths(): string[] {
return [...(this.settings.skills ?? [])];
}
@@ -1065,13 +939,6 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserSkillPaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.skills = paths;
this.markProjectUserModified("skills");
this.saveProjectUserSettings(projectUserSettings);
}
getPromptTemplatePaths(): string[] {
return [...(this.settings.prompts ?? [])];
}
@@ -1089,13 +956,6 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserPromptTemplatePaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.prompts = paths;
this.markProjectUserModified("prompts");
this.saveProjectUserSettings(projectUserSettings);
}
getThemePaths(): string[] {
return [...(this.settings.themes ?? [])];
}
@@ -1113,13 +973,6 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserThemePaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.themes = paths;
this.markProjectUserModified("themes");
this.saveProjectUserSettings(projectUserSettings);
}
getEnableSkillCommands(): boolean {
return this.settings.enableSkillCommands ?? true;
}
@@ -36,6 +36,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
{ name: "trust", description: "Configure project .pi/.pi.user trust" },
{ name: "trust", description: "Configure project .pi trust" }
{ name: "quit", description: `Quit ${APP_NAME}` },
];
@@ -15,7 +15,7 @@ export function createSourceInfo(path: string, metadata: PathMetadata): SourceIn
return {
path,
source: metadata.source,
scope: metadata.scope === "projectUser" ? "project" : metadata.scope,
scope: metadata.scope,
origin: metadata.origin,
baseDir: metadata.baseDir,
};
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME } from "../config.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
export type ProjectTrustDecision = boolean | null;
@@ -53,7 +53,7 @@ function writeTrustFile(path: string, data: TrustFile): void {
export function hasProjectConfig(cwd: string): boolean {
const resolvedCwd = resolvePath(cwd);
return existsSync(join(resolvedCwd, CONFIG_DIR_NAME)) || existsSync(join(resolvedCwd, PROJECT_USER_CONFIG_DIR_NAME));
return existsSync(join(resolvedCwd, CONFIG_DIR_NAME));
}
export class ProjectTrustStore {
-5
View File
@@ -152,15 +152,12 @@ export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"
export { convertToLlm } from "./core/messages.ts";
export { ModelRegistry } from "./core/model-registry.ts";
export type {
InstalledSourceScope,
PackageManager,
PathMetadata,
ProgressCallback,
ProgressEvent,
ResolvedPaths,
ResolvedResource,
ResourceMetadataScope,
SettingsSourceScope,
} from "./core/package-manager.ts";
export { DefaultPackageManager } from "./core/package-manager.ts";
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts";
@@ -220,8 +217,6 @@ export {
type CompactionSettings,
type ImageSettings,
type PackageSource,
type ProjectSettingsLayer,
type ProjectSettingsScope,
type RetrySettings,
SettingsManager,
type SettingsManagerCreateOptions,
+1 -1
View File
@@ -508,7 +508,7 @@ interface ProjectTrustPromptResult {
async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise<ProjectTrustPromptResult> {
const selected = await showStartupSelector(
settingsManager,
`Trust project configuration?\nLoad .pi and .pi.user from ${cwd}?\nWarning: Project extensions can execute code.`,
`Trust project configuration?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
[
{ label: "Yes (remember)", value: { trusted: true, remember: true } },
{ label: "Yes (this session)", value: { trusted: true, remember: false } },
@@ -15,7 +15,7 @@ import {
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../../../config.ts";
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { theme } from "../theme/theme.ts";
@@ -50,7 +50,7 @@ interface ResourceSubgroup {
interface ResourceGroup {
key: string;
label: string;
scope: PathMetadata["scope"];
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
source: string;
subgroups: ResourceSubgroup[];
@@ -75,29 +75,18 @@ function formatBaseDir(baseDir: string): string {
function getGroupLabel(metadata: PathMetadata): string {
if (metadata.origin === "package") {
return `${metadata.source} (${metadata.scope === "projectUser" ? "project user" : metadata.scope})`;
return `${metadata.source} (${metadata.scope})`;
}
// Top-level resources
if (metadata.source === "auto") {
if (metadata.baseDir) {
if (metadata.scope === "user") {
return `User (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "projectUser"
? `Project user (${formatBaseDir(metadata.baseDir)})`
return metadata.scope === "user"
? `User (${formatBaseDir(metadata.baseDir)})`
: `Project (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "user"
? "User (~/.pi/agent/)"
: metadata.scope === "projectUser"
? "Project user (.pi.user/)"
: "Project (.pi/)";
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
}
return metadata.scope === "user"
? "User settings"
: metadata.scope === "projectUser"
? "Project user settings"
: "Project settings";
return metadata.scope === "user" ? "User settings" : "Project settings";
}
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
@@ -159,15 +148,14 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
addToGroup(resolved.prompts, "prompts");
addToGroup(resolved.themes, "themes");
// Sort groups: packages first, then top-level; user before project.
const scopeOrder: Record<PathMetadata["scope"], number> = { user: 0, projectUser: 1, project: 2, temporary: 3 };
// Sort groups: packages first, then top-level; user before project
const groups = Array.from(groupMap.values());
groups.sort((a, b) => {
if (a.origin !== b.origin) {
return a.origin === "package" ? -1 : 1;
}
if (a.scope !== b.scope) {
return scopeOrder[a.scope] - scopeOrder[b.scope];
return a.scope === "user" ? -1 : 1;
}
return a.source.localeCompare(b.source);
});
@@ -466,27 +454,10 @@ class ResourceList implements Component, Focusable {
}
}
private getSettingsScope(item: ResourceItem): "user" | "project" | "projectUser" {
return item.metadata.scope === "projectUser"
? "projectUser"
: item.metadata.scope === "project"
? "project"
: "user";
}
private getSettingsForScope(scope: "user" | "project" | "projectUser") {
if (scope === "projectUser") {
return this.settingsManager.getProjectUserSettings();
}
if (scope === "project") {
return this.settingsManager.getProjectSettings();
}
return this.settingsManager.getGlobalSettings();
}
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
const scope = this.getSettingsScope(item);
const settings = this.getSettingsForScope(scope);
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes";
const current = (settings[arrayKey] ?? []) as string[];
@@ -508,17 +479,7 @@ class ResourceList implements Component, Focusable {
updated.push(disablePattern);
}
if (scope === "projectUser") {
if (arrayKey === "extensions") {
this.settingsManager.setProjectUserExtensionPaths(updated);
} else if (arrayKey === "skills") {
this.settingsManager.setProjectUserSkillPaths(updated);
} else if (arrayKey === "prompts") {
this.settingsManager.setProjectUserPromptTemplatePaths(updated);
} else if (arrayKey === "themes") {
this.settingsManager.setProjectUserThemePaths(updated);
}
} else if (scope === "project") {
if (scope === "project") {
if (arrayKey === "extensions") {
this.settingsManager.setProjectExtensionPaths(updated);
} else if (arrayKey === "skills") {
@@ -542,8 +503,9 @@ class ResourceList implements Component, Focusable {
}
private togglePackageResource(item: ResourceItem, enabled: boolean): void {
const scope = this.getSettingsScope(item);
const settings = this.getSettingsForScope(scope);
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
@@ -592,24 +554,19 @@ class ResourceList implements Component, Focusable {
packages[pkgIndex] = (pkg as { source: string }).source;
}
if (scope === "projectUser") {
this.settingsManager.setProjectUserPackages(packages);
} else if (scope === "project") {
if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
} else {
this.settingsManager.setPackages(packages);
}
}
private getTopLevelBaseDir(scope: "user" | "project" | "projectUser"): string {
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME);
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
private getResourcePattern(item: ResourceItem): string {
const scope = this.getSettingsScope(item);
const scope = item.metadata.scope as "user" | "project";
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
@@ -4954,7 +4954,7 @@ export class InteractiveMode {
const cwd = this.sessionManager.getCwd();
const current = this.formatTrustDecision(trustStore.get(cwd));
const choice = await this.showExtensionSelector(
`Trust project configuration?\nCurrent setting: ${current}\nLoad .pi and .pi.user from ${cwd}?\nWarning: Project extensions can execute code.`,
`Trust project configuration?\nCurrent setting: ${current}\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
["Yes (remember)", "Yes (this session)", "No (remember)", "No (this session)"],
);
if (choice === "Yes (remember)") {
@@ -48,7 +48,6 @@ interface PackageCommandOptions {
source?: string;
updateTarget?: UpdateTarget;
local: boolean;
localUser: boolean;
force: boolean;
help: boolean;
invalidOption?: string;
@@ -75,9 +74,9 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string)
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l] [-u]`;
return `${APP_NAME} install <source> [-l]`;
case "remove":
return `${APP_NAME} remove <source> [-l] [-u]`;
return `${APP_NAME} remove <source> [-l]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
case "list":
@@ -95,7 +94,6 @@ Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-u, --user With --local, write to .pi.user/settings.json instead
-f, --force Trust project config for this command
Examples:
@@ -113,11 +111,10 @@ Examples:
${getPackageCommandUsage("remove")}
Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l] [-u]
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-u, --user With --local, remove from .pi.user/settings.json instead
-f, --force Trust project config for this command
Examples:
@@ -171,7 +168,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
let local = false;
let localUser = false;
let force = false;
let help = false;
let invalidOption: string | undefined;
@@ -199,15 +195,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "-u" || arg === "--user") {
if (command === "install" || command === "remove") {
localUser = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg === "--self") {
if (command === "update") {
selfFlag = true;
@@ -294,16 +281,11 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
}
if (localUser && !local) {
conflictingOptions = conflictingOptions ?? "--user can only be used with --local";
}
return {
command,
source,
updateTarget,
local,
localUser,
force,
help,
invalidOption,
@@ -525,15 +507,12 @@ export async function handlePackageCommand(
try {
switch (options.command) {
case "install":
await packageManager.installAndPersist(source!, { local: options.local, localUser: options.localUser });
await packageManager.installAndPersist(source!, { local: options.local });
console.log(chalk.green(`Installed ${source}`));
return true;
case "remove": {
const removed = await packageManager.removeAndPersist(source!, {
local: options.local,
localUser: options.localUser,
});
const removed = await packageManager.removeAndPersist(source!, { local: options.local });
if (!removed) {
console.error(chalk.red(`No matching package found for ${source}`));
process.exitCode = 1;
@@ -547,7 +526,6 @@ export async function handlePackageCommand(
const configuredPackages = packageManager.listConfiguredPackages();
const userPackages = configuredPackages.filter((pkg) => pkg.scope === "user");
const projectPackages = configuredPackages.filter((pkg) => pkg.scope === "project");
const projectUserPackages = configuredPackages.filter((pkg) => pkg.scope === "projectUser");
if (configuredPackages.length === 0) {
console.log(chalk.dim("No packages installed."));
@@ -577,14 +555,6 @@ export async function handlePackageCommand(
}
}
if (projectUserPackages.length > 0) {
if (userPackages.length > 0 || projectPackages.length > 0) console.log();
console.log(chalk.bold("Project user packages:"));
for (const pkg of projectUserPackages) {
formatPackage(pkg);
}
}
return true;
}
@@ -171,7 +171,7 @@ describe("package commands", () => {
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain('Unknown option --unknown for "install".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [-u]".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".');
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { PassThrough } from "node:stream";
@@ -33,6 +33,10 @@ interface PackageManagerInternals {
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
): Promise<string>;
getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>;
getGitInstallPath(
source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string },
scope: "user" | "project" | "temporary",
): string;
}
// Helper to check if a resource is enabled
@@ -156,38 +160,6 @@ Content`,
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
});
it("should resolve project user paths relative to .pi.user", async () => {
const extDir = join(tempDir, ".pi.user", "extensions");
mkdirSync(extDir, { recursive: true });
const extPath = join(extDir, "project-user-ext.ts");
writeFileSync(extPath, "export default function() {}");
settingsManager.setProjectUserExtensionPaths(["extensions/project-user-ext.ts"]);
const result = await packageManager.resolve();
const ext = result.extensions.find((r) => r.path === extPath);
expect(ext?.enabled).toBe(true);
expect(ext?.metadata.scope).toBe("projectUser");
});
it("should prefer .pi.user resources over .pi resources", async () => {
const projectExtDir = join(tempDir, ".pi", "extensions");
const projectUserExtDir = join(tempDir, ".pi.user", "extensions");
mkdirSync(projectExtDir, { recursive: true });
mkdirSync(projectUserExtDir, { recursive: true });
const projectExtPath = join(projectExtDir, "shared.ts");
const projectUserExtPath = join(projectUserExtDir, "shared.ts");
writeFileSync(projectExtPath, "export default function() {}");
writeFileSync(projectUserExtPath, "export default function() {}");
const result = await packageManager.resolve();
const sharedPaths = result.extensions.filter((r) => r.path.endsWith("shared.ts"));
expect(sharedPaths).toHaveLength(2);
expect(sharedPaths[0].path).toBe(projectUserExtPath);
expect(sharedPaths[1].path).toBe(projectExtPath);
});
it("should auto-discover user prompts with overrides", async () => {
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
@@ -740,21 +712,6 @@ Content`,
);
});
it("should create ignored .pi.user folder for project user npm installs", async () => {
expect(existsSync(join(tempDir, ".pi.user"))).toBe(false);
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
await packageManager.install("npm:@scope/pkg", { local: true, localUser: true });
expect(readFileSync(join(tempDir, ".pi.user", ".gitignore"), "utf-8")).toBe("*\n.*\n");
expect(runCommandSpy).toHaveBeenCalledWith(
"npm",
["install", "@scope/pkg", "--prefix", join(tempDir, ".pi.user", "npm"), "--legacy-peer-deps"],
undefined,
);
});
it("should use bun --cwd for npm package installs", async () => {
settingsManager = SettingsManager.inMemory({
npmCommand: ["mise", "exec", "bun@1", "--", "bun"],
@@ -1185,6 +1142,25 @@ Content`,
});
});
describe("git install paths", () => {
it("should reject paths outside git install roots", () => {
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
const traversalSource = {
type: "git" as const,
repo: "git@evil.example:../../victim/repo",
host: "evil.example",
path: "../../victim/repo",
pinned: false,
};
for (const scope of ["user", "project", "temporary"] as const) {
expect(() => managerWithInternals.getGitInstallPath(traversalSource, scope)).toThrow(
"outside package install root",
);
}
});
});
describe("settings source normalization", () => {
it("should store global local packages relative to agent settings base", () => {
const pkgDir = join(tempDir, "packages", "local-global-pkg");
@@ -1214,23 +1190,6 @@ Content`,
expect(settings.packages?.[0]).toBe(expected);
});
it("should store project user local packages relative to .pi.user settings base", () => {
const projectPkgDir = join(tempDir, "project-user-local-pkg");
mkdirSync(join(projectPkgDir, "extensions"), { recursive: true });
writeFileSync(join(projectPkgDir, "extensions", "index.ts"), "export default function() {}");
const added = packageManager.addSourceToSettings("./project-user-local-pkg", {
local: true,
localUser: true,
});
expect(added).toBe(true);
const settings = settingsManager.getProjectUserSettings();
const rel = relative(join(tempDir, ".pi.user"), projectPkgDir);
const expected = rel.startsWith(".") ? rel : `./${rel}`;
expect(settings.packages?.[0]).toBe(expected);
});
it("should remove local package entries using equivalent path forms", () => {
const pkgDir = join(tempDir, "remove-local-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
@@ -329,19 +329,6 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should prefer SYSTEM.md from cwd/.pi.user", async () => {
const piDir = join(cwd, ".pi");
const piUserDir = join(cwd, ".pi.user");
mkdirSync(piDir, { recursive: true });
mkdirSync(piUserDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(piUserDir, "SYSTEM.md"), "Project user system prompt.");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Project user system prompt.");
});
it("should skip .pi SYSTEM.md when project config is not trusted", async () => {
const piDir = join(cwd, ".pi");
@@ -258,43 +258,15 @@ describe("SettingsManager", () => {
});
});
describe("project user settings", () => {
it("should let .pi.user override .pi and global settings", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
mkdirSync(join(projectDir, ".pi.user"), { recursive: true });
writeFileSync(join(projectDir, ".pi.user", "settings.json"), JSON.stringify({ theme: "project-user" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTheme()).toBe("project-user");
expect(manager.getProjectSettings().theme).toBe("project");
expect(manager.getProjectUserSettings().theme).toBe("project-user");
});
describe("project config trust", () => {
it("should skip project settings when project config is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
mkdirSync(join(projectDir, ".pi.user"), { recursive: true });
writeFileSync(join(projectDir, ".pi.user", "settings.json"), JSON.stringify({ theme: "project-user" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectConfigTrusted: false });
expect(manager.getTheme()).toBe("global");
expect(manager.getProjectSettings()).toEqual({});
expect(manager.getProjectUserSettings()).toEqual({});
expect(manager.getProjectSettingsLayers()).toEqual([]);
});
it("should create ignored .pi.user folder when writing project user settings", async () => {
rmSync(join(projectDir, ".pi.user"), { recursive: true, force: true });
const manager = SettingsManager.create(projectDir, agentDir);
manager.setProjectUserPackages(["npm:test-pkg"]);
await manager.flush();
expect(existsSync(join(projectDir, ".pi.user", "settings.json"))).toBe(true);
expect(readFileSync(join(projectDir, ".pi.user", ".gitignore"), "utf-8")).toBe("*\n.*\n");
});
});
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -43,15 +43,10 @@ describe("ProjectTrustStore", () => {
expect(readFileSync(trustPath, "utf-8")).toBe("{not json");
});
it("detects .pi and .pi.user project config directories", () => {
it("detects .pi project config directories", () => {
expect(hasProjectConfig(cwd)).toBe(false);
mkdirSync(join(cwd, ".pi"), { recursive: true });
expect(hasProjectConfig(cwd)).toBe(true);
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
mkdirSync(join(cwd, ".pi.user"), { recursive: true });
expect(hasProjectConfig(cwd)).toBe(true);
expect(existsSync(join(cwd, ".pi.user"))).toBe(true);
});
});