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