From bc8c65c91913cf78e9aa95ce648f11047ecbe0f5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 23:28:50 +0200 Subject: [PATCH] refactor(coding-agent): drop project user config scope --- packages/coding-agent/README.md | 25 +- packages/coding-agent/docs/extensions.md | 4 +- packages/coding-agent/docs/packages.md | 8 +- .../coding-agent/docs/prompt-templates.md | 2 +- packages/coding-agent/docs/settings.md | 16 +- packages/coding-agent/docs/skills.md | 3 +- packages/coding-agent/docs/themes.md | 2 +- packages/coding-agent/docs/usage.md | 13 +- packages/coding-agent/src/cli/args.ts | 8 +- packages/coding-agent/src/config.ts | 1 - .../coding-agent/src/core/package-manager.ts | 399 ++++++++---------- .../src/core/project-user-config.ts | 14 - .../coding-agent/src/core/resource-loader.ts | 29 +- .../coding-agent/src/core/settings-manager.ts | 177 +------- .../coding-agent/src/core/slash-commands.ts | 2 +- packages/coding-agent/src/core/source-info.ts | 2 +- .../coding-agent/src/core/trust-manager.ts | 4 +- packages/coding-agent/src/index.ts | 5 - packages/coding-agent/src/main.ts | 2 +- .../interactive/components/config-selector.ts | 81 +--- .../src/modes/interactive/interactive-mode.ts | 2 +- .../coding-agent/src/package-manager-cli.ts | 40 +- .../test/package-command-paths.test.ts | 2 +- .../coding-agent/test/package-manager.test.ts | 89 ++-- .../coding-agent/test/resource-loader.test.ts | 13 - .../test/settings-manager.test.ts | 30 +- .../coding-agent/test/trust-manager.test.ts | 9 +- 27 files changed, 292 insertions(+), 690 deletions(-) delete mode 100644 packages/coding-agent/src/core/project-user-config.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index c7b7efb32..1b89fc1ce 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 [-l] [-u] # Install package, -l for project-local, -u for .pi.user with -l -pi remove [-l] [-u] # Remove package -pi uninstall [-l] [-u] # Alias for remove +pi install [-l] # Install package, -l for project-local +pi remove [-l] # Remove package +pi uninstall [-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 ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | 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 | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index af09aaf91..a8b453a63 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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`: diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 59d613705..335271914 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -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//` (global), `.pi/git//` (project), or `.pi.user/git//` (project-local user). +- Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (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 diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 4bdbb7204..056d2c9ac 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -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 ` (repeatable) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 6685fb449..4c27ee2fa 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 } } ``` diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index c52f8c62c..cb96dd978 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -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 ` (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 diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 778c7f3d0..8762943d3 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -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 ` (repeatable) diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index de972bb96..053592e9b 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 [-l] [-u] # Install package, -l for project-local, -u for .pi.user with -l -pi remove [-l] [-u] # Remove package -pi uninstall [-l] [-u] # Alias for remove +pi install [-l] # Install package, -l for project-local +pi remove [-l] # Remove package +pi uninstall [-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 ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | 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 | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 81c47b0a0..94b924a35 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -224,9 +224,9 @@ ${chalk.bold("Usage:")} ${APP_NAME} [options] [@files...] [messages...] ${chalk.bold("Commands:")} - ${APP_NAME} install [-l] [-u] Install extension source and add to settings - ${APP_NAME} remove [-l] [-u] Remove extension source from settings - ${APP_NAME} uninstall [-l] [-u] Alias for remove + ${APP_NAME} install [-l] Install extension source and add to settings + ${APP_NAME} remove [-l] Remove extension source from settings + ${APP_NAME} uninstall [-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 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 diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 64ca9eb20..65fb220e4 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -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 diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 20152f798..7c0e96a17 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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; } export interface ConfiguredPackage { source: string; - scope: InstalledSourceScope; + scope: "user" | "project"; filtered: boolean; installedPath?: string; } export interface PackageManager { resolve(onMissing?: (source: string) => Promise): Promise; - install(source: string, options?: { local?: boolean; localUser?: boolean }): Promise; - installAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise; - remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise; - removeAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise; + install(source: string, options?: { local?: boolean }): Promise; + installAndPersist(source: string, options?: { local?: boolean }): Promise; + remove(source: string, options?: { local?: boolean }): Promise; + removeAndPersist(source: string, options?: { local?: boolean }): Promise; update(source?: string): Promise; listConfiguredPackages(): ConfiguredPackage[]; resolveExtensionSources( sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise; - 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; 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): Promise { 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 { + async install(source: string, options?: { local?: boolean }): Promise { 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 { + async installAndPersist(source: string, options?: { local?: boolean }): Promise { await this.install(source, options); this.addSourceToSettings(source, options); } - async remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise { + async remove(source: string, options?: { local?: boolean }): Promise { 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 { + async removeAndPersist(source: string, options?: { local?: boolean }): Promise { await this.remove(source, options); return this.removeSourceFromSettings(source, options); } async update(source?: string): Promise { 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(); + 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[] = []; - 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 } => + entry.scope !== "temporary", + ) .map((entry) => async (): Promise => { 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 { - 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 { - 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, - projectLayers: ReturnType, + projectSettings: ReturnType, 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((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", diff --git a/packages/coding-agent/src/core/project-user-config.ts b/packages/coding-agent/src/core/project-user-config.ts deleted file mode 100644 index b14acceab..000000000 --- a/packages/coding-agent/src/core/project-user-config.ts +++ /dev/null @@ -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"); - } -} diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 68aa85af6..c980e9efd 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -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"); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 85224ddde..1aea86bab 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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(); // Track global fields modified during session private modifiedNestedFields = new Map>(); // Track global nested field modifications private modifiedProjectFields = new Set(); // Track project fields modified during session private modifiedProjectNestedFields = new Map>(); // Track project nested field modifications - private modifiedProjectUserFields = new Set(); // Track .pi.user fields modified during session - private modifiedProjectUserNestedFields = new Map>(); // 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 = 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 { 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; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 677cd84d3..1eb4cf237 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -36,6 +36,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { 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}` }, ]; diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts index 9e99e4fee..c8c9837d1 100644 --- a/packages/coding-agent/src/core/source-info.ts +++ b/packages/coding-agent/src/core/source-info.ts @@ -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, }; diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 7449edaab..4b641a343 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -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 { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 01936c0ee..b5bb19135 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -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, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 91808301c..e3275f239 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -508,7 +508,7 @@ interface ProjectTrustPromptResult { async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise { 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 } }, diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 16e8ffd42..93ef4bea0 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -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 = { 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); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index dc0e02c9c..1228ca957 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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)") { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 207cb86e3..3ff90e5c0 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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 [-l] [-u]`; + return `${APP_NAME} install [-l]`; case "remove": - return `${APP_NAME} remove [-l] [-u]`; + return `${APP_NAME} remove [-l]`; case "update": return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--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 [-l] [-u] +Alias: ${APP_NAME} uninstall [-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; } diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 13203da08..7be08b778 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -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 [-l] [-u]".'); + expect(stderr).toContain('Use "pi --help" or "pi install [-l]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 04863675e..848b37bde 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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 }, ): Promise; 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 }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 45ccae1a6..cd5c628d7 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -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"); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 2b86c0885..7fc6725d1 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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"); }); }); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 2e7d12873..ceed813ce 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -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); }); });