From 2c7c31000be92cc316a4e67929f809f9c6a6e760 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 17:03:16 +0200 Subject: [PATCH] feat(coding-agent): add project config trust approvals --- 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 | 18 +- packages/coding-agent/docs/skills.md | 3 +- packages/coding-agent/docs/themes.md | 2 +- packages/coding-agent/docs/usage.md | 15 +- packages/coding-agent/src/cli/args.ts | 12 +- packages/coding-agent/src/config.ts | 1 + .../coding-agent/src/core/package-manager.ts | 380 ++++++++++-------- .../src/core/project-user-config.ts | 14 + .../coding-agent/src/core/resource-loader.ts | 36 +- .../coding-agent/src/core/settings-manager.ts | 237 ++++++++++- .../coding-agent/src/core/slash-commands.ts | 1 + packages/coding-agent/src/core/source-info.ts | 2 +- .../coding-agent/src/core/trust-manager.ts | 92 +++++ packages/coding-agent/src/index.ts | 6 + packages/coding-agent/src/main.ts | 199 ++++++++- packages/coding-agent/src/migrations.ts | 2 + .../interactive/components/config-selector.ts | 81 +++- .../src/modes/interactive/interactive-mode.ts | 96 +++++ .../coding-agent/src/package-manager-cli.ts | 94 ++++- packages/coding-agent/test/args.test.ts | 12 + .../test/package-command-paths.test.ts | 118 +++++- .../coding-agent/test/package-manager.test.ts | 66 ++- .../coding-agent/test/resource-loader.test.ts | 27 ++ .../test/settings-manager.test.ts | 40 ++ .../coding-agent/test/trust-manager.test.ts | 57 +++ 29 files changed, 1376 insertions(+), 274 deletions(-) create mode 100644 packages/coding-agent/src/core/project-user-config.ts create mode 100644 packages/coding-agent/src/core/trust-manager.ts create mode 100644 packages/coding-agent/test/trust-manager.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0cf352551..764ac07eb 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -190,6 +190,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 | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display version history | | `/quit` | Quit pi | @@ -282,9 +283,14 @@ 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. + ### Telemetry and update checks Pi has two separate startup features: @@ -309,7 +315,7 @@ Disable context file loading with `--no-context-files` (or `-nc`). ### System Prompt -Replace the default system prompt with `.pi/SYSTEM.md` (project) or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md`. +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. --- @@ -325,7 +331,7 @@ Review this code for bugs, security issues, and performance problems. Focus on: {{focus}} ``` -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). +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). ### Skills @@ -341,7 +347,7 @@ Use this skill when the user asks about X. 2. Then that ``` -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). +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). ### Extensions @@ -373,13 +379,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/`, 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/`, `.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/). ### 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/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md). +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). ### Pi Packages @@ -409,7 +415,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/`). 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/`), 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"]`. Create a package by adding a `pi` key to `package.json`: @@ -497,9 +503,9 @@ pi [options] [@files...] [messages...] ### Package Commands ```bash -pi install [-l] # Install package, -l for project-local -pi remove [-l] # Remove package -pi uninstall [-l] # Alias for remove +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 update [source|self|pi] # Update pi and packages (skips pinned packages) pi update --extensions # Update packages only pi update --self # Update pi only @@ -582,6 +588,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 | | `-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 a8b453a63..af09aaf91 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) 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`. +> **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`. **Key capabilities:** - **Custom tools** - Register tools the LLM can call via `pi.registerTool()` @@ -117,6 +117,8 @@ 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 335271914..59d613705 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. 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. 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. 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 installs go under `.pi/npm/`; project-local user installs go under `.pi.user/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) or `.pi/git//` (project). +- Cloned to `~/.pi/agent/git//` (global), `.pi/git//` (project), or `.pi.user/git//` (project-local user). - 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 both global and project settings. If the same package appears in both, the project entry wins. Identity is determined by: +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: - 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 056d2c9ac..4bdbb7204 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` +- Project: `.pi/prompts/*.md`, `.pi.user/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 47aecc4a6..6685fb449 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -1,14 +1,19 @@ # Settings -Pi uses JSON settings files with project settings overriding global settings. +Pi uses JSON settings files with project settings overriding global settings and `.pi.user` overriding shared project 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. + ## All Settings ### Model & Thinking @@ -193,7 +198,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`. 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`; paths in `.pi.user/settings.json` resolve relative to `.pi.user`. Absolute paths and `~` are supported. | Setting | Type | Default | Description | |---------|------|---------|-------------| @@ -259,7 +264,7 @@ See [packages.md](packages.md) for package management details. ## Project Overrides -Project settings (`.pi/settings.json`) override global settings. Nested objects are merged: +Project settings (`.pi/settings.json`) override global settings. Project-local user settings (`.pi.user/settings.json`) override both. Nested objects are merged: ```json // ~/.pi/agent/settings.json (global) @@ -273,9 +278,14 @@ Project settings (`.pi/settings.json`) override global settings. Nested objects "compaction": { "reserveTokens": 8192 } } +// .pi.user/settings.json (project-local user) +{ + "theme": "light" +} + // Result { - "theme": "dark", + "theme": "light", "compaction": { "enabled": true, "reserveTokens": 8192 } } ``` diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index cb96dd978..c52f8c62c 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -28,13 +28,14 @@ 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/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills +- In `~/.pi/agent/skills/`, `.pi/skills/`, and `.pi.user/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 8762943d3..778c7f3d0 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` +- Project: `.pi/themes/*.json`, `.pi.user/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 47d08769c..267268d6f 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -51,6 +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 | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display version history | | `/quit` | Quit pi | @@ -105,10 +106,15 @@ 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 -Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location. +Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in those locations. + +### 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. ## Exporting and Sharing Sessions @@ -127,9 +133,9 @@ pi [options] [@files...] [messages...] ### Package Commands ```bash -pi install [-l] # Install package, -l for project-local -pi remove [-l] # Remove package -pi uninstall [-l] # Alias for remove +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 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 @@ -219,6 +225,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 | | `-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 2decae247..711249e1c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -46,6 +46,7 @@ export interface Args { listModels?: string | true; offline?: boolean; verbose?: boolean; + force?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -176,6 +177,8 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--verbose") { result.verbose = true; + } else if (arg === "--force" || arg === "-f") { + result.force = true; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -221,11 +224,11 @@ ${chalk.bold("Usage:")} ${APP_NAME} [options] [@files...] [messages...] ${chalk.bold("Commands:")} - ${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} 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} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list List installed extensions from settings + ${APP_NAME} list [--force] List installed extensions from settings ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list @@ -266,6 +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 --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 65fb220e4..64ca9eb20 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -465,6 +465,7 @@ 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 1d7dc27d3..20152f798 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,11 +27,12 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { CONFIG_DIR_NAME } from "../config.ts"; +import { CONFIG_DIR_NAME, PROJECT_USER_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; @@ -44,9 +45,12 @@ 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: SourceScope; + scope: ResourceMetadataScope; origin: "package" | "top-level"; baseDir?: string; } @@ -79,32 +83,32 @@ export interface PackageUpdate { source: string; displayName: string; type: "npm" | "git"; - scope: Exclude; + scope: InstalledSourceScope; } export interface ConfiguredPackage { source: string; - scope: "user" | "project"; + scope: InstalledSourceScope; filtered: boolean; installedPath?: string; } export interface PackageManager { resolve(onMissing?: (source: string) => Promise): 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; + 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; update(source?: string): Promise; listConfiguredPackages(): ConfiguredPackage[]; resolveExtensionSources( sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise; - addSourceToSettings(source: string, options?: { local?: boolean }): boolean; - removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean; + addSourceToSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean; + removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean; setProgressCallback(callback: ProgressCallback | undefined): void; - getInstalledPath(source: string, scope: "user" | "project"): string | undefined; + getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined; } interface PackageManagerOptions { @@ -113,7 +117,7 @@ interface PackageManagerOptions { settingsManager: SettingsManager; } -type SourceScope = "user" | "project" | "temporary"; +type SourceScope = SettingsSourceScope | "temporary"; type NpmSource = { type: "npm"; @@ -129,7 +133,7 @@ type LocalSource = { type ParsedSource = NpmSource | GitSource | LocalSource; -type InstalledSourceScope = Exclude; +export type InstalledSourceScope = SettingsSourceScope; interface ConfiguredUpdateSource { source: string; @@ -164,15 +168,17 @@ interface ResourceAccumulator { * name-collision resolution ("first wins") produces the correct outcome. * * Precedence (highest to lowest): - * 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") + * 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 */ function resourcePrecedenceRank(m: PathMetadata): number { - if (m.origin === "package") return 4; - const scopeBase = m.scope === "project" ? 0 : 2; + if (m.origin === "package") return 6; + const scopeBase = m.scope === "projectUser" ? 0 : m.scope === "project" ? 2 : m.scope === "user" ? 4 : 6; return scopeBase + (m.source === "local" ? 0 : 1); } @@ -772,10 +778,36 @@ export class DefaultPackageManager implements PackageManager { this.progressCallback = callback; } - addSourceToSettings(source: string, options?: { local?: boolean }): boolean { - const scope: SourceScope = options?.local ? "project" : "user"; - const currentSettings = - scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + 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); const currentPackages = currentSettings.packages ?? []; const normalizedSource = this.normalizePackageSourceForSettings(source, scope); const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope)); @@ -787,41 +819,28 @@ export class DefaultPackageManager implements PackageManager { const nextPackages = [...currentPackages]; nextPackages[matchIndex] = typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource }; - if (scope === "project") { - this.settingsManager.setProjectPackages(nextPackages); - } else { - this.settingsManager.setPackages(nextPackages); - } + this.setPackagesForScope(scope, nextPackages); return true; } const nextPackages = [...currentPackages, normalizedSource]; - if (scope === "project") { - this.settingsManager.setProjectPackages(nextPackages); - } else { - this.settingsManager.setPackages(nextPackages); - } + this.setPackagesForScope(scope, nextPackages); return true; } - removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean { - const scope: SourceScope = options?.local ? "project" : "user"; - const currentSettings = - scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean { + const scope = this.getScopeForOptions(options); + const currentSettings = this.getSettingsForScope(scope); const currentPackages = currentSettings.packages ?? []; const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope)); const changed = nextPackages.length !== currentPackages.length; if (!changed) { return false; } - if (scope === "project") { - this.settingsManager.setProjectPackages(nextPackages); - } else { - this.settingsManager.setPackages(nextPackages); - } + this.setPackagesForScope(scope, nextPackages); return true; } - getInstalledPath(source: string, scope: "user" | "project"): string | undefined { + getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined { const parsed = this.parseSource(source); if (parsed.type === "npm") { const path = this.getNpmInstallPath(parsed, scope); @@ -863,39 +882,42 @@ export class DefaultPackageManager implements PackageManager { async resolve(onMissing?: (source: string) => Promise): Promise { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); - const projectSettings = this.settingsManager.getProjectSettings(); + const projectLayers = this.settingsManager.getProjectSettingsLayers(); - // Collect all packages with scope (project first so cwd resources win collisions) + // Collect all packages with scope (project-local user first so it wins collisions) const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; - for (const pkg of projectSettings.packages ?? []) { - allPackages.push({ pkg, scope: "project" }); + for (const layer of projectLayers) { + for (const pkg of layer.settings.packages ?? []) { + allPackages.push({ pkg, scope: layer.scope }); + } } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); } - // Dedupe: project scope wins over global for same package identity + // Dedupe: earlier scopes win for the 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[]; - const projectEntries = (projectSettings[resourceType] ?? []) as string[]; - this.resolveLocalEntries( - projectEntries, - resourceType, - target, - { - source: "local", - scope: "project", - origin: "top-level", - }, - projectBaseDir, - ); + 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), + ); + } this.resolveLocalEntries( globalEntries, resourceType, @@ -909,7 +931,7 @@ export class DefaultPackageManager implements PackageManager { ); } - this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); + this.addAutoDiscoveredResources(accumulator, globalSettings, projectLayers, globalBaseDir); return this.toResolvedPaths(accumulator); } @@ -927,7 +949,7 @@ export class DefaultPackageManager implements PackageManager { listConfiguredPackages(): ConfiguredPackage[] { const globalSettings = this.settingsManager.getGlobalSettings(); - const projectSettings = this.settingsManager.getProjectSettings(); + const projectLayers = this.settingsManager.getProjectSettingsLayers(); const configuredPackages: ConfiguredPackage[] = []; for (const pkg of globalSettings.packages ?? []) { @@ -940,22 +962,24 @@ export class DefaultPackageManager implements PackageManager { }); } - 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"), - }); + 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), + }); + } } return configuredPackages; } - async install(source: string, options?: { local?: boolean }): Promise { + async install(source: string, options?: { local?: boolean; localUser?: boolean }): Promise { const parsed = this.parseSource(source); - const scope: SourceScope = options?.local ? "project" : "user"; + const scope: SourceScope = this.getScopeForOptions(options); await this.withProgress("install", source, `Installing ${source}...`, async () => { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); @@ -976,14 +1000,14 @@ export class DefaultPackageManager implements PackageManager { }); } - async installAndPersist(source: string, options?: { local?: boolean }): Promise { + async installAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise { await this.install(source, options); this.addSourceToSettings(source, options); } - async remove(source: string, options?: { local?: boolean }): Promise { + async remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise { const parsed = this.parseSource(source); - const scope: SourceScope = options?.local ? "project" : "user"; + const scope: SourceScope = this.getScopeForOptions(options); await this.withProgress("remove", source, `Removing ${source}...`, async () => { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); @@ -1000,14 +1024,14 @@ export class DefaultPackageManager implements PackageManager { }); } - async removeAndPersist(source: string, options?: { local?: boolean }): Promise { + async removeAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise { await this.remove(source, options); return this.removeSourceFromSettings(source, options); } async update(source?: string): Promise { const globalSettings = this.settingsManager.getGlobalSettings(); - const projectSettings = this.settingsManager.getProjectSettings(); + const projectLayers = this.settingsManager.getProjectSettingsLayers(); const identity = source ? this.getPackageIdentity(source) : undefined; let matched = false; const updateSources: ConfiguredUpdateSource[] = []; @@ -1018,18 +1042,20 @@ export class DefaultPackageManager implements PackageManager { matched = true; updateSources.push({ source: sourceStr, scope: "user" }); } - 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" }); + 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 }); + } } if (source && !matched) { throw new Error( this.buildNoMatchingPackageMessage(source, [ ...(globalSettings.packages ?? []), - ...(projectSettings.packages ?? []), + ...projectLayers.flatMap((layer) => layer.settings.packages ?? []), ]), ); } @@ -1063,25 +1089,21 @@ export class DefaultPackageManager implements PackageManager { shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope), })); const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY); - const userNpmUpdates: NpmUpdateTarget[] = []; - const projectNpmUpdates: NpmUpdateTarget[] = []; + const npmUpdatesByScope = new Map(); for (const result of npmCheckResults) { if (!result.shouldUpdate) { continue; } - if (result.entry.scope === "user") { - userNpmUpdates.push(result.entry); - } else { - projectNpmUpdates.push(result.entry); - } + const updates = npmUpdatesByScope.get(result.entry.scope) ?? []; + updates.push(result.entry); + npmUpdatesByScope.set(result.entry.scope, updates); } const tasks: Promise[] = []; - if (userNpmUpdates.length > 0) { - tasks.push(this.updateNpmBatch(userNpmUpdates, "user")); - } - if (projectNpmUpdates.length > 0) { - tasks.push(this.updateNpmBatch(projectNpmUpdates, "project")); + for (const [scope, updates] of npmUpdatesByScope.entries()) { + if (updates.length > 0) { + tasks.push(this.updateNpmBatch(updates, scope)); + } } if (gitCandidates.length > 0) { const gitTasks = gitCandidates.map( @@ -1138,10 +1160,12 @@ export class DefaultPackageManager implements PackageManager { } const globalSettings = this.settingsManager.getGlobalSettings(); - const projectSettings = this.settingsManager.getProjectSettings(); + const projectLayers = this.settingsManager.getProjectSettingsLayers(); const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; - for (const pkg of projectSettings.packages ?? []) { - allPackages.push({ pkg, scope: "project" }); + for (const layer of projectLayers) { + for (const pkg of layer.settings.packages ?? []) { + allPackages.push({ pkg, scope: layer.scope }); + } } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); @@ -1149,10 +1173,7 @@ export class DefaultPackageManager implements PackageManager { const packageSources = this.dedupePackages(allPackages); const checks = packageSources - .filter( - (entry): entry is { pkg: PackageSource; scope: Exclude } => - entry.scope !== "temporary", - ) + .filter((entry): entry is { pkg: PackageSource; scope: InstalledSourceScope } => entry.scope !== "temporary") .map((entry) => async (): Promise => { const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; const parsed = this.parseSource(source); @@ -1206,7 +1227,11 @@ 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); @@ -1630,8 +1655,8 @@ export class DefaultPackageManager implements PackageManager { } /** - * Dedupe packages: if same package identity appears in both global and project, - * keep only the project one (project wins). + * Dedupe packages by identity. Callers pass sources in precedence order, so the + * first occurrence wins. */ private dedupePackages( packages: Array<{ pkg: PackageSource; scope: SourceScope }>, @@ -1642,15 +1667,10 @@ export class DefaultPackageManager implements PackageManager { const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; const identity = this.getPackageIdentity(sourceStr, entry.scope); - const existing = seen.get(identity); - if (!existing) { - seen.set(identity, entry); - } else if (entry.scope === "project" && existing.scope === "user") { - // Project wins over user + if (!seen.has(identity)) { seen.set(identity, entry); } - // If existing is project and new is global, keep existing (project) - // If both are same scope, keep first one + // Callers pass sources in precedence order. Keep the first matching identity. } return Array.from(seen.values()); @@ -1728,6 +1748,9 @@ 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)); @@ -1746,6 +1769,9 @@ 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) { @@ -1881,10 +1907,17 @@ 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"); } @@ -1925,6 +1958,9 @@ 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); } @@ -1952,6 +1988,9 @@ 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); + } if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path); } @@ -1962,6 +2001,9 @@ export class DefaultPackageManager implements PackageManager { 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"); } @@ -1977,6 +2019,9 @@ export class DefaultPackageManager implements PackageManager { } 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); } @@ -2196,9 +2241,8 @@ export class DefaultPackageManager implements PackageManager { private addAutoDiscoveredResources( accumulator: ResourceAccumulator, globalSettings: ReturnType, - projectSettings: ReturnType, + projectLayers: ReturnType, globalBaseDir: string, - projectBaseDir: string, ): void { const userMetadata: PathMetadata = { source: "auto", @@ -2206,11 +2250,11 @@ export class DefaultPackageManager implements PackageManager { origin: "top-level", baseDir: globalBaseDir, }; - const projectMetadata: PathMetadata = { + const projectAgentMetadata: PathMetadata = { source: "auto", scope: "project", origin: "top-level", - baseDir: projectBaseDir, + baseDir: join(this.cwd, CONFIG_DIR_NAME), }; const userOverrides = { @@ -2219,11 +2263,11 @@ export class DefaultPackageManager implements PackageManager { prompts: (globalSettings.prompts ?? []) as string[], themes: (globalSettings.themes ?? []) as string[], }; - const projectOverrides = { - extensions: (projectSettings.extensions ?? []) as string[], - skills: (projectSettings.skills ?? []) as string[], - prompts: (projectSettings.prompts ?? []) as string[], - themes: (projectSettings.themes ?? []) as string[], + const projectSkillOverrides = [...projectLayers] + .reverse() + .reduce((skills, layer) => layer.settings.skills ?? skills, []); + const projectAgentOverrides = { + skills: projectSkillOverrides, }; const userDirs = { @@ -2232,12 +2276,6 @@ 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), @@ -2257,55 +2295,73 @@ export class DefaultPackageManager implements PackageManager { } }; - // Project extensions from .pi/ - addResources( - "extensions", - collectAutoExtensionEntries(projectDirs.extensions), - projectMetadata, - projectOverrides.extensions, - projectBaseDir, - ); + 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 skills from .pi/ - addResources( - "skills", - collectAutoSkillEntries(projectDirs.skills, "pi"), - projectMetadata, - projectOverrides.skills, - 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 .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory const agentsMetadata: PathMetadata = { - ...projectMetadata, + ...projectAgentMetadata, baseDir: agentsBaseDir, }; addResources( "skills", collectAutoSkillEntries(agentsSkillsDir, "agents"), agentsMetadata, - projectOverrides.skills, + projectAgentOverrides.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 new file mode 100644 index 000000000..b14acceab --- /dev/null +++ b/packages/coding-agent/src/core/project-user-config.ts @@ -0,0 +1,14 @@ +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 eb043cd56..68aa85af6 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 } from "../config.ts"; +import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts"; import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts"; import type { ResourceDiagnostic } from "./diagnostics.ts"; @@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts"; +import { DefaultPackageManager, type MissingSourceAction, type PathMetadata } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -145,6 +145,7 @@ export interface DefaultResourceLoaderOptions { agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; + onMissingPackage?: (source: string) => Promise; systemPromptOverride?: (base: string | undefined) => string | undefined; appendSystemPromptOverride?: (base: string[]) => string[]; } @@ -183,6 +184,7 @@ export class DefaultResourceLoader implements ResourceLoader { private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; + private onMissingPackage?: (source: string) => Promise; private systemPromptOverride?: (base: string | undefined) => string | undefined; private appendSystemPromptOverride?: (base: string[]) => string[]; @@ -230,6 +232,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.promptsOverride = options.promptsOverride; this.themesOverride = options.themesOverride; this.agentsFilesOverride = options.agentsFilesOverride; + this.onMissingPackage = options.onMissingPackage; this.systemPromptOverride = options.systemPromptOverride; this.appendSystemPromptOverride = options.appendSystemPromptOverride; @@ -320,7 +323,7 @@ export class DefaultResourceLoader implements ResourceLoader { async reload(): Promise { await this.settingsManager.reload(); - const resolvedPaths = await this.packageManager.resolve(); + const resolvedPaths = await this.packageManager.resolve(this.onMissingPackage); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); @@ -649,6 +652,10 @@ 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"), @@ -851,9 +858,13 @@ export class DefaultResourceLoader implements ResourceLoader { } private discoverSystemPromptFile(): string | undefined { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); - if (existsSync(projectPath)) { - return projectPath; + 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 globalPath = join(this.agentDir, "SYSTEM.md"); @@ -865,9 +876,16 @@ export class DefaultResourceLoader implements ResourceLoader { } private discoverAppendSystemPromptFile(): string | undefined { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (existsSync(projectPath)) { - return projectPath; + 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 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 910b68aab..85224ddde 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -2,9 +2,10 @@ 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 } from "../config.ts"; +import { CONFIG_DIR_NAME, getAgentDir, PROJECT_USER_CONFIG_DIR_NAME } 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 @@ -157,10 +158,22 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde return undefined; } -export type SettingsScope = "global" | "project"; +export type SettingsScope = "global" | "project" | "projectUser"; +export type ProjectSettingsScope = "project" | "projectUser"; + +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; + isProjectConfigTrusted?(): boolean; } export interface SettingsError { @@ -171,12 +184,24 @@ 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) { + constructor(cwd: string, agentDir: string, options: SettingsManagerCreateOptions = {}) { const resolvedCwd = resolvePath(cwd); 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; + } + + setProjectConfigTrusted(trusted: boolean): void { + this.projectConfigTrusted = trusted; + } + + isProjectConfigTrusted(): boolean { + return this.projectConfigTrusted; } private acquireLockSyncWithRetry(path: string): () => void { @@ -206,8 +231,24 @@ 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 { - const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath; + if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) { + fn(undefined); + return; + } + + const path = this.getSettingsPath(scope); const dir = dirname(path); let release: (() => void) | undefined; @@ -221,7 +262,9 @@ export class FileSettingsStorage implements SettingsStorage { const next = fn(current); if (next !== undefined) { // Only create directory when we actually need to write - if (!existsSync(dir)) { + if (scope === "projectUser") { + ensureIgnoredProjectUserConfigDir(dir); + } else if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } if (!release) { @@ -240,15 +283,32 @@ 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 { + this.projectConfigTrusted = trusted; + } + + isProjectConfigTrusted(): boolean { + return this.projectConfigTrusted; + } withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { - const current = scope === "global" ? this.global : this.project; + if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) { + fn(undefined); + return; + } + + const current = scope === "global" ? this.global : scope === "project" ? this.project : this.projectUser; const next = fn(current); if (next !== undefined) { if (scope === "global") { this.global = next; - } else { + } else if (scope === "project") { this.project = next; + } else { + this.projectUser = next; } } } @@ -258,13 +318,18 @@ 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[]; @@ -272,29 +337,53 @@ 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 = deepMergeSettings(this.globalSettings, this.projectSettings); + 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(); } /** Create a SettingsManager that loads from files */ - static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { - const storage = new FileSettingsStorage(cwd, agentDir); + static create( + cwd: string, + agentDir: string = getAgentDir(), + options: SettingsManagerCreateOptions = {}, + ): SettingsManager { + const storage = new FileSettingsStorage(cwd, agentDir, options); return SettingsManager.fromStorage(storage); } /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage): 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 }); @@ -302,14 +391,20 @@ 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, ); } @@ -416,6 +511,40 @@ 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; + } + + setProjectConfigTrusted(trusted: boolean): void { + this.projectConfigTrusted = trusted; + 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(); + } + } + async reload(): Promise { await this.writeQueue; const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); @@ -431,6 +560,8 @@ 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) { @@ -441,7 +572,16 @@ export class SettingsManager { this.recordError("project", projectLoad.error); } - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + 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(); } /** Apply additional overrides on top of current settings */ @@ -471,6 +611,17 @@ 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 }); @@ -482,9 +633,14 @@ export class SettingsManager { this.modifiedNestedFields.clear(); return; } + if (scope === "project") { + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + return; + } - this.modifiedProjectFields.clear(); - this.modifiedProjectNestedFields.clear(); + this.modifiedProjectUserFields.clear(); + this.modifiedProjectUserNestedFields.clear(); } private enqueueWrite(scope: SettingsScope, task: () => void): void { @@ -538,7 +694,7 @@ export class SettingsManager { } private save(): void { - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.rebuildSettings(); if (this.globalSettingsLoadError) { return; @@ -555,7 +711,7 @@ export class SettingsManager { private saveProjectSettings(settings: Settings): void { this.projectSettings = structuredClone(settings); - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.rebuildSettings(); if (this.projectSettingsLoadError) { return; @@ -569,6 +725,22 @@ 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; } @@ -845,6 +1017,13 @@ 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 ?? [])]; } @@ -862,6 +1041,13 @@ 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 ?? [])]; } @@ -879,6 +1065,13 @@ 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 ?? [])]; } @@ -896,6 +1089,13 @@ 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 ?? [])]; } @@ -913,6 +1113,13 @@ 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 e52892c81..677cd84d3 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -36,5 +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: "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 c8c9837d1..9e99e4fee 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, + scope: metadata.scope === "projectUser" ? "project" : 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 new file mode 100644 index 000000000..7449edaab --- /dev/null +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -0,0 +1,92 @@ +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 { canonicalizePath, resolvePath } from "../utils/paths.ts"; + +export type ProjectTrustDecision = boolean | null; + +type TrustFile = Record; + +function normalizeCwd(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function readTrustFile(path: string): TrustFile { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read trust store ${path}: ${message}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${path}: expected an object`); + } + + const data: TrustFile = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value !== true && value !== false && value !== null) { + throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`); + } + data[key] = value; + } + return data; +} + +function writeTrustFile(path: string, data: TrustFile): void { + const sorted: TrustFile = {}; + for (const key of Object.keys(data).sort()) { + const value = data[key]; + if (value === true || value === false || value === null) { + sorted[key] = value; + } + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +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)); +} + +export class ProjectTrustStore { + private trustPath: string; + + constructor(agentDir: string) { + this.trustPath = join(resolvePath(agentDir), "trust.json"); + } + + get(cwd: string): ProjectTrustDecision { + const data = readTrustFile(this.trustPath); + const value = data[normalizeCwd(cwd)]; + return value === true || value === false ? value : null; + } + + set(cwd: string, decision: ProjectTrustDecision): void { + const trustDir = dirname(this.trustPath); + mkdirSync(trustDir, { recursive: true }); + let release: (() => void) | undefined; + try { + // Lock before reading or creating trust.json so malformed content cannot be + // silently replaced by a concurrent or follow-up write. + release = lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${this.trustPath}.lock` }); + const data = readTrustFile(this.trustPath); + const key = normalizeCwd(cwd); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } + writeTrustFile(this.trustPath, data); + } finally { + release?.(); + } + } +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0cdfc7260..01936c0ee 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -152,12 +152,15 @@ 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"; @@ -217,8 +220,11 @@ export { type CompactionSettings, type ImageSettings, type PackageSource, + type ProjectSettingsLayer, + type ProjectSettingsScope, type RetrySettings, SettingsManager, + type SettingsManagerCreateOptions, } from "./core/settings-manager.ts"; // Skills export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 353e48156..91808301c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -40,12 +40,17 @@ import { import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; -import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; +import { + handleConfigCommand, + handlePackageCommand, + packageCommandForcesProjectConfigTrust, +} from "./package-manager-cli.ts"; +import { canonicalizePath, isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; /** @@ -436,10 +441,25 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -async function promptForMissingSessionCwd( - issue: SessionCwdIssue, +function getSessionTrustOverrideKey(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function isPackageCommandArg(arg: string | undefined): boolean { + return arg === "install" || arg === "remove" || arg === "uninstall" || arg === "update" || arg === "list"; +} + +function hasForceFlag(args: string[]): boolean { + return args.includes("--force") || args.includes("-f"); +} + +async function showStartupSelector( settingsManager: SettingsManager, -): Promise { + title: string, + options: Array<{ label: string; value: T }>, +): Promise { + // Startup prompts run before resource loading. Themes from packages, --themes, + // and project config are unavailable here; built-ins and user theme files work. initTheme(settingsManager.getTheme()); setKeybindings(KeybindingsManager.create()); @@ -448,7 +468,7 @@ async function promptForMissingSessionCwd( ui.setClearOnShrink(settingsManager.getClearOnShrink()); let settled = false; - const finish = (result: string | undefined) => { + const finish = (result: T | undefined) => { if (settled) { return; } @@ -458,9 +478,9 @@ async function promptForMissingSessionCwd( }; const selector = new ExtensionSelectorComponent( - formatMissingSessionCwdPrompt(issue), - ["Continue", "Cancel"], - (option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), + title, + options.map((option) => option.label), + (option) => finish(options.find((entry) => entry.label === option)?.value), () => finish(undefined), { tui: ui }, ); @@ -470,6 +490,71 @@ async function promptForMissingSessionCwd( }); } +async function promptForMissingSessionCwd( + issue: SessionCwdIssue, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [ + { label: "Continue", value: issue.fallbackCwd }, + { label: "Cancel", value: undefined }, + ]); +} + +interface ProjectTrustPromptResult { + trusted: boolean; + remember: boolean; +} + +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.`, + [ + { label: "Yes (remember)", value: { trusted: true, remember: true } }, + { label: "Yes (this session)", value: { trusted: true, remember: false } }, + { label: "No (remember)", value: { trusted: false, remember: true } }, + { label: "No (this session)", value: { trusted: false, remember: false } }, + ], + ); + return selected ?? { trusted: false, remember: false }; +} + +interface ProjectTrustResolution { + trusted: boolean; + sessionOverride?: boolean; +} + +async function resolveProjectConfigTrusted(options: { + cwd: string; + agentDir: string; + sessionTrustOverride: boolean | undefined; + appMode: AppMode; + settingsManagerForPrompt: SettingsManager; +}): Promise { + if (options.sessionTrustOverride !== undefined) { + return { trusted: options.sessionTrustOverride }; + } + if (!hasProjectConfig(options.cwd)) { + return { trusted: false }; + } + + const trustStore = new ProjectTrustStore(options.agentDir); + const decision = trustStore.get(options.cwd); + if (decision !== null) { + return { trusted: decision }; + } + if (options.appMode !== "interactive") { + return { trusted: false }; + } + + const result = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); + if (result.remember) { + trustStore.set(options.cwd, result.trusted); + return { trusted: result.trusted }; + } + return { trusted: result.trusted, sessionOverride: result.trusted }; +} + export interface MainOptions { extensionFactories?: ExtensionFactory[]; } @@ -486,12 +571,27 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } - if (await handlePackageCommand(args)) { - return; - } - - if (await handleConfigCommand(args)) { - return; + if (isPackageCommandArg(args[0]) || args[0] === "config") { + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const projectConfigExists = hasProjectConfig(cwd); + const forceProjectConfigTrusted = + args[0] === "config" ? hasForceFlag(args) : packageCommandForcesProjectConfigTrust(args); + const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); + const projectTrustResolution = await resolveProjectConfigTrusted({ + cwd, + agentDir, + sessionTrustOverride: forceProjectConfigTrusted ? true : undefined, + appMode: process.stdin.isTTY ? "interactive" : "print", + settingsManagerForPrompt: promptSettingsManager, + }); + const projectConfigTrusted = forceProjectConfigTrusted || projectTrustResolution.trusted; + if (await handlePackageCommand(args, { projectConfigTrusted, projectConfigExists })) { + return; + } + if (await handleConfigCommand(args, { projectConfigTrusted, projectConfigExists })) { + return; + } } const parsed = parseArgs(args); @@ -538,13 +638,35 @@ export async function main(args: string[], options?: MainOptions) { validateForkFlags(parsed); validateSessionIdFlags(parsed); - // Run migrations (pass cwd for project-local migrations) - const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); - time("runMigrations"); - const cwd = process.cwd(); const agentDir = getAgentDir(); - const startupSettingsManager = SettingsManager.create(cwd, agentDir); + const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); + const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; + const forceProjectConfigTrusted = parsed.force === true; + const sessionTrustOverrides = new Map(); + const getSessionTrustOverride = (targetCwd: string): boolean | undefined => { + return forceProjectConfigTrusted ? true : sessionTrustOverrides.get(getSessionTrustOverrideKey(targetCwd)); + }; + const startupTrustResolution = await resolveProjectConfigTrusted({ + cwd, + agentDir, + sessionTrustOverride: getSessionTrustOverride(cwd), + appMode: trustPromptMode, + settingsManagerForPrompt: promptSettingsManager, + }); + if (startupTrustResolution.sessionOverride !== undefined) { + sessionTrustOverrides.set(getSessionTrustOverrideKey(cwd), startupTrustResolution.sessionOverride); + } + const startupProjectConfigTrusted = startupTrustResolution.trusted; + // Legacy extension migrations are intentional filesystem housekeeping and run + // regardless of trust. Trust gates loading/executing project config, not moving + // old Pi config directories to the current layout. + const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd); + time("runMigrations"); + + const startupSettingsManager = SettingsManager.create(cwd, agentDir, { + projectConfigTrusted: startupProjectConfigTrusted, + }); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); // Decide the final runtime cwd before creating cwd-bound runtime services. @@ -581,6 +703,25 @@ export async function main(args: string[], options?: MainOptions) { } time("createSessionManager"); + const initialRuntimeCwd = sessionManager.getCwd(); + let runtimeProjectConfigTrusted = startupProjectConfigTrusted; + if (initialRuntimeCwd !== cwd) { + const runtimeTrustResolution = await resolveProjectConfigTrusted({ + cwd: initialRuntimeCwd, + agentDir, + sessionTrustOverride: getSessionTrustOverride(initialRuntimeCwd), + appMode: trustPromptMode, + settingsManagerForPrompt: promptSettingsManager, + }); + if (runtimeTrustResolution.sessionOverride !== undefined) { + sessionTrustOverrides.set( + getSessionTrustOverrideKey(initialRuntimeCwd), + runtimeTrustResolution.sessionOverride, + ); + } + runtimeProjectConfigTrusted = runtimeTrustResolution.trusted; + } + const trustStore = new ProjectTrustStore(agentDir); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); @@ -592,10 +733,15 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, sessionStartEvent, }) => { + const projectConfigTrusted = + getSessionTrustOverride(cwd) ?? + (cwd === initialRuntimeCwd ? runtimeProjectConfigTrusted : trustStore.get(cwd) === true); + const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted }); const services = await createAgentSessionServices({ cwd, agentDir, authStorage, + settingsManager: runtimeSettingsManager, extensionFlagValues: parsed.unknownFlags, resourceLoaderOptions: { additionalExtensionPaths: resolvedExtensionPaths, @@ -750,6 +896,19 @@ export async function main(args: string[], options?: MainOptions) { initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, + forceProjectConfigTrust: forceProjectConfigTrusted, + setProjectConfigTrustOverride: (overrideCwd, trusted) => { + const key = getSessionTrustOverrideKey(overrideCwd); + if (trusted === undefined) { + sessionTrustOverrides.delete(key); + } else { + sessionTrustOverrides.set(key, trusted); + } + if (key === getSessionTrustOverrideKey(initialRuntimeCwd)) { + runtimeProjectConfigTrusted = + getSessionTrustOverride(initialRuntimeCwd) ?? trustStore.get(initialRuntimeCwd) === true; + } + }, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index eb07f220e..98cc59dde 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -384,6 +384,8 @@ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[] /** * Run extension system migrations (commands→prompts) and collect warnings about deprecated directories. + * This intentionally runs even for untrusted projects: it performs legacy Pi config + * housekeeping only and does not load or execute project extensions. */ function migrateExtensionSystem(cwd: string): string[] { const agentDir = getAgentDir(); 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 93ef4bea0..16e8ffd42 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 } from "../../../config.ts"; +import { CONFIG_DIR_NAME, PROJECT_USER_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: "user" | "project" | "temporary"; + scope: PathMetadata["scope"]; origin: "package" | "top-level"; source: string; subgroups: ResourceSubgroup[]; @@ -75,18 +75,29 @@ function formatBaseDir(baseDir: string): string { function getGroupLabel(metadata: PathMetadata): string { if (metadata.origin === "package") { - return `${metadata.source} (${metadata.scope})`; + return `${metadata.source} (${metadata.scope === "projectUser" ? "project user" : metadata.scope})`; } // Top-level resources if (metadata.source === "auto") { if (metadata.baseDir) { - return metadata.scope === "user" - ? `User (${formatBaseDir(metadata.baseDir)})` + if (metadata.scope === "user") { + return `User (${formatBaseDir(metadata.baseDir)})`; + } + return metadata.scope === "projectUser" + ? `Project user (${formatBaseDir(metadata.baseDir)})` : `Project (${formatBaseDir(metadata.baseDir)})`; } - return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)"; + return metadata.scope === "user" + ? "User (~/.pi/agent/)" + : metadata.scope === "projectUser" + ? "Project user (.pi.user/)" + : "Project (.pi/)"; } - return metadata.scope === "user" ? "User settings" : "Project settings"; + return metadata.scope === "user" + ? "User settings" + : metadata.scope === "projectUser" + ? "Project user settings" + : "Project settings"; } function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { @@ -148,14 +159,15 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { addToGroup(resolved.prompts, "prompts"); addToGroup(resolved.themes, "themes"); - // Sort groups: packages first, then top-level; user before project + // Sort groups: packages first, then top-level; user before project. + const scopeOrder: Record = { user: 0, projectUser: 1, project: 2, temporary: 3 }; 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 a.scope === "user" ? -1 : 1; + return scopeOrder[a.scope] - scopeOrder[b.scope]; } return a.source.localeCompare(b.source); }); @@ -454,10 +466,27 @@ 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 = item.metadata.scope as "user" | "project"; - const settings = - scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + const scope = this.getSettingsScope(item); + const settings = this.getSettingsForScope(scope); const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; const current = (settings[arrayKey] ?? []) as string[]; @@ -479,7 +508,17 @@ class ResourceList implements Component, Focusable { updated.push(disablePattern); } - if (scope === "project") { + 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 (arrayKey === "extensions") { this.settingsManager.setProjectExtensionPaths(updated); } else if (arrayKey === "skills") { @@ -503,9 +542,8 @@ class ResourceList implements Component, Focusable { } private togglePackageResource(item: ResourceItem, enabled: boolean): void { - const scope = item.metadata.scope as "user" | "project"; - const settings = - scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + const scope = this.getSettingsScope(item); + const settings = this.getSettingsForScope(scope); const packages = [...(settings.packages ?? [])] as PackageSource[]; const pkgIndex = packages.findIndex((pkg) => { @@ -554,19 +592,24 @@ class ResourceList implements Component, Focusable { packages[pkgIndex] = (pkg as { source: string }).source; } - if (scope === "project") { + if (scope === "projectUser") { + this.settingsManager.setProjectUserPackages(packages); + } else if (scope === "project") { this.settingsManager.setProjectPackages(packages); } else { this.settingsManager.setPackages(packages); } } - private getTopLevelBaseDir(scope: "user" | "project"): string { + private getTopLevelBaseDir(scope: "user" | "project" | "projectUser"): string { + if (scope === "projectUser") { + return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME); + } return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir; } private getResourcePattern(item: ResourceItem): string { - const scope = item.metadata.scope as "user" | "project"; + const scope = this.getSettingsScope(item); 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 b874495b6..dc0e02c9c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; +import { hasProjectConfig, type ProjectTrustDecision, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -255,6 +256,10 @@ export interface InteractiveModeOptions { initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; + /** Force project config trust for this process */ + forceProjectConfigTrust?: boolean; + /** Update project config trust override for this session and cwd */ + setProjectConfigTrustOverride?: (cwd: string, trusted: boolean | undefined) => void; } export class InteractiveMode { @@ -681,6 +686,12 @@ export class InteractiveMode { this.headerContainer.addChild(this.builtInHeader); } + if (!this.settingsManager.isProjectConfigTrusted() && hasProjectConfig(this.sessionManager.getCwd())) { + this.chatContainer.addChild( + new Text(theme.fg("warning", "This project is not trusted. Change with /trust"), 1, 0), + ); + } + this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); @@ -2590,6 +2601,11 @@ export class InteractiveMode { await this.handleReloadCommand(); return; } + if (text === "/trust" || text.startsWith("/trust ")) { + this.editor.setText(""); + await this.handleTrustCommand(text); + return; + } if (text === "/debug") { this.handleDebugCommand(); this.editor.setText(""); @@ -4923,6 +4939,86 @@ export class InteractiveMode { // Command handlers // ========================================================================= + private formatTrustDecision(decision: ProjectTrustDecision): string { + if (decision === true) { + return "trusted"; + } + if (decision === false) { + return "not trusted"; + } + return "ask"; + } + + private async selectTrustDecision(): Promise<{ decision: ProjectTrustDecision; remember: boolean } | undefined> { + const trustStore = new ProjectTrustStore(getAgentDir()); + 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.`, + ["Yes (remember)", "Yes (this session)", "No (remember)", "No (this session)"], + ); + if (choice === "Yes (remember)") { + return { decision: true, remember: true }; + } + if (choice === "Yes (this session)") { + return { decision: true, remember: false }; + } + if (choice === "No (remember)") { + return { decision: false, remember: true }; + } + if (choice === "No (this session)") { + return { decision: false, remember: false }; + } + return undefined; + } + + private async handleTrustCommand(text: string): Promise { + if (this.session.isStreaming) { + this.showWarning("Wait for the current response to finish before changing trust."); + return; + } + if (this.session.isCompacting) { + this.showWarning("Wait for compaction to finish before changing trust."); + return; + } + + const rawArg = text === "/trust" ? "" : text.slice("/trust".length).trim().toLowerCase(); + let selection: { decision: ProjectTrustDecision; remember: boolean } | undefined; + if (!rawArg) { + selection = await this.selectTrustDecision(); + } else if (rawArg === "yes") { + selection = { decision: true, remember: true }; + } else if (rawArg === "no") { + selection = { decision: false, remember: true }; + } else if (rawArg === "reset") { + selection = { decision: null, remember: true }; + } else { + this.showError("Usage: /trust [yes|no|reset]"); + return; + } + + if (selection === undefined) { + return; + } + + const trustStore = new ProjectTrustStore(getAgentDir()); + const cwd = this.sessionManager.getCwd(); + if (selection.remember) { + trustStore.set(cwd, selection.decision); + this.options.setProjectConfigTrustOverride?.(cwd, undefined); + } else { + this.options.setProjectConfigTrustOverride?.( + cwd, + selection.decision === null ? undefined : selection.decision, + ); + } + const projectConfigTrusted = this.options.forceProjectConfigTrust === true || selection.decision === true; + this.settingsManager.setProjectConfigTrusted(projectConfigTrusted); + await this.handleReloadCommand(); + const suffix = selection.remember ? "" : " (this session)"; + this.showStatus(`Project trust: ${this.formatTrustDecision(selection.decision)}${suffix}`); + } + private async handleReloadCommand(): Promise { if (this.session.isStreaming) { this.showWarning("Wait for the current response to finish before reloading."); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index f61d4ff2a..207cb86e3 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -14,6 +14,7 @@ import { } from "./config.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; +import { hasProjectConfig } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -47,6 +48,7 @@ interface PackageCommandOptions { source?: string; updateTarget?: UpdateTarget; local: boolean; + localUser: boolean; force: boolean; help: boolean; invalidOption?: string; @@ -55,6 +57,11 @@ interface PackageCommandOptions { conflictingOptions?: string; } +interface ProjectConfigCommandContext { + projectConfigTrusted?: boolean; + projectConfigExists?: boolean; +} + function reportSettingsErrors(settingsManager: SettingsManager, context: string): void { const errors = settingsManager.drainErrors(); for (const { scope, error } of errors) { @@ -68,13 +75,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string) function getPackageCommandUsage(command: PackageCommand): string { switch (command) { case "install": - return `${APP_NAME} install [-l]`; + return `${APP_NAME} install [-l] [-u]`; case "remove": - return `${APP_NAME} remove [-l]`; + return `${APP_NAME} remove [-l] [-u]`; case "update": return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; case "list": - return `${APP_NAME} list`; + return `${APP_NAME} list [--force]`; } } @@ -88,6 +95,8 @@ 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: ${APP_NAME} install npm:@foo/bar @@ -104,10 +113,12 @@ Examples: ${getPackageCommandUsage("remove")} Remove a package and its source from settings. -Alias: ${APP_NAME} uninstall [-l] +Alias: ${APP_NAME} uninstall [-l] [-u] 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: ${APP_NAME} remove npm:@foo/bar @@ -139,6 +150,9 @@ Short forms: ${getPackageCommandUsage("list")} List installed packages from user and project settings. + +Options: + -f, --force Trust project config for this command `); return; } @@ -157,6 +171,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } let local = false; + let localUser = false; let force = false; let help = false; let invalidOption: string | undefined; @@ -184,6 +199,15 @@ 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; @@ -202,12 +226,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } - if (arg === "--force") { - if (command === "update") { - force = true; - } else { - invalidOption = invalidOption ?? arg; - } + if (arg === "--force" || arg === "-f") { + force = true; continue; } @@ -274,11 +294,16 @@ 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, @@ -288,6 +313,14 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined }; } +export function packageCommandForcesProjectConfigTrust(args: string[]): boolean { + const options = parsePackageCommand(args); + return ( + options?.force === true && + (options.command === "install" || options.command === "remove" || options.command === "list") + ); +} + function updateTargetIncludesSelf(target: UpdateTarget): boolean { return target.type === "all" || target.type === "self"; } @@ -389,14 +422,16 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } -export async function handleConfigCommand(args: string[]): Promise { +export async function handleConfigCommand(args: string[], context: ProjectConfigCommandContext = {}): Promise { if (args[0] !== "config") { return false; } const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const settingsManager = SettingsManager.create(cwd, agentDir, { + projectConfigTrusted: context.projectConfigTrusted ?? true, + }); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -411,7 +446,10 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } -export async function handlePackageCommand(args: string[]): Promise { +export async function handlePackageCommand( + args: string[], + context: ProjectConfigCommandContext = {}, +): Promise { const options = parsePackageCommand(args); if (!options) { return false; @@ -459,8 +497,20 @@ export async function handlePackageCommand(args: string[]): Promise { } const cwd = process.cwd(); + const projectConfigTrusted = context.projectConfigTrusted ?? true; + const projectConfigExists = context.projectConfigExists ?? hasProjectConfig(cwd); + const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + if (!projectConfigTrusted && projectConfigExists && writesProjectPackageConfig) { + console.error(chalk.red("Project config is not trusted. Use --force to modify local package config.")); + process.exitCode = 1; + return true; + } + const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const effectiveProjectConfigTrusted = projectConfigTrusted || (writesProjectPackageConfig && !projectConfigExists); + const settingsManager = SettingsManager.create(cwd, agentDir, { + projectConfigTrusted: effectiveProjectConfigTrusted, + }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; @@ -475,12 +525,15 @@ export async function handlePackageCommand(args: string[]): Promise { try { switch (options.command) { case "install": - await packageManager.installAndPersist(source!, { local: options.local }); + await packageManager.installAndPersist(source!, { local: options.local, localUser: options.localUser }); console.log(chalk.green(`Installed ${source}`)); return true; case "remove": { - const removed = await packageManager.removeAndPersist(source!, { local: options.local }); + const removed = await packageManager.removeAndPersist(source!, { + local: options.local, + localUser: options.localUser, + }); if (!removed) { console.error(chalk.red(`No matching package found for ${source}`)); process.exitCode = 1; @@ -494,6 +547,7 @@ export async function handlePackageCommand(args: string[]): Promise { 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.")); @@ -523,6 +577,14 @@ export async function handlePackageCommand(args: string[]): Promise { } } + 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/args.test.ts b/packages/coding-agent/test/args.test.ts index 78fd832c8..c58a09f4c 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -300,6 +300,18 @@ describe("parseArgs", () => { }); }); + describe("--force flag", () => { + test("parses --force flag", () => { + const result = parseArgs(["--force"]); + expect(result.force).toBe(true); + }); + + test("parses -f shorthand", () => { + const result = parseArgs(["-f"]); + expect(result.force).toBe(true); + }); + }); + describe("--offline flag", () => { test("parses --offline flag", () => { const result = parseArgs(["--offline"]); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 5e4a1a48a..13203da08 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -85,6 +85,66 @@ describe("package commands", () => { expect(removedSettings.packages ?? []).toHaveLength(0); }); + it("skips untrusted project package settings", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + } finally { + logSpy.mockRestore(); + } + }); + + it("forces project trust for list with --force", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--force"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("blocks local package changes when project config is untrusted", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Project config is not trusted. Use --force to modify local package config."); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("allows local package install to initialize fresh project config", async () => { + await main(["install", "-l", packageDir]); + + const settingsPath = join(projectDir, ".pi", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; + expect(settings.packages?.length).toBe(1); + const stored = settings.packages?.[0] ?? ""; + expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir)); + expect(process.exitCode).toBeUndefined(); + }); + it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -111,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]".'); + expect(stderr).toContain('Use "pi --help" or "pi install [-l] [-u]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); @@ -134,6 +194,60 @@ describe("package commands", () => { } }); + it("does not treat update --force as a project trust override", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm-record.cjs"); + const recordPath = join(tempDir, "update-force-records.json"); + mkdirSync(selfPackageDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) { + console.log(path.join(prefix,"lib","node_modules")); + process.exit(0); +} +const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[]; +records.push(args); +fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:@project/pkg"] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--force"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(recordPath)).toBe(true); + const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; + expect(recordedCalls.some((args) => args.some((arg) => arg.includes("@project/pkg")))).toBe(false); + expect(recordedCalls).toEqual([expect.arrayContaining(["install", "-g", PACKAGE_NAME])]); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("uses global npmCommand and current package name for forced self updates without checking the api", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index af3bc99d3..04863675e 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 { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; @@ -156,6 +156,38 @@ 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 }); @@ -708,6 +740,21 @@ 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"], @@ -1167,6 +1214,23 @@ 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 693f05c9e..45ccae1a6 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -329,6 +329,33 @@ 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"); + mkdirSync(piDir, { recursive: true }); + writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt."); + writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt."); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); + + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("Global system prompt."); + }); + it("should discover APPEND_SYSTEM.md", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index a503e3ccc..2b86c0885 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -258,6 +258,46 @@ 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"); + }); + + 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"); + }); + }); + describe("httpIdleTimeoutMs", () => { it("should default to 5 minutes", () => { const manager = SettingsManager.create(projectDir, agentDir); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts new file mode 100644 index 000000000..2e7d12873 --- /dev/null +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -0,0 +1,57 @@ +import { existsSync, 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"; +import { hasProjectConfig, ProjectTrustStore } from "../src/core/trust-manager.ts"; + +describe("ProjectTrustStore", () => { + let tempDir: string; + let agentDir: string; + let cwd: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + cwd = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stores decisions per cwd", () => { + const store = new ProjectTrustStore(agentDir); + + expect(store.get(cwd)).toBeNull(); + store.set(cwd, true); + expect(store.get(cwd)).toBe(true); + store.set(cwd, false); + expect(store.get(cwd)).toBe(false); + store.set(cwd, null); + expect(store.get(cwd)).toBeNull(); + }); + + it("fails loudly without overwriting malformed trust stores", () => { + const trustPath = join(agentDir, "trust.json"); + writeFileSync(trustPath, "{not json", "utf-8"); + const store = new ProjectTrustStore(agentDir); + + expect(() => store.get(cwd)).toThrow(/Failed to read trust store/); + expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/); + expect(readFileSync(trustPath, "utf-8")).toBe("{not json"); + }); + + it("detects .pi and .pi.user 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); + }); +});