feat(coding-agent): add project config trust approvals

This commit is contained in:
Armin Ronacher
2026-06-02 17:03:16 +02:00
Unverified
parent 13898f048f
commit 2c7c31000b
29 changed files with 1376 additions and 274 deletions
+16 -9
View File
@@ -190,6 +190,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/export [file]` | Export session to HTML file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
| `/trust [yes|no|reset]` | Configure whether `.pi` and `.pi.user` are trusted for this working directory |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |
@@ -282,9 +283,14 @@ Use `/settings` to modify common options, or edit JSON files directly:
|----------|-------|
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (overrides global) |
| `.pi.user/settings.json` | Project-local user overrides (ignored by Git when Pi creates it) |
See [docs/settings.md](docs/settings.md) for all options.
### Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
### Telemetry and update checks
Pi has two separate startup features:
@@ -309,7 +315,7 @@ Disable context file loading with `--no-context-files` (or `-nc`).
### System Prompt
Replace the default system prompt with `.pi/SYSTEM.md` (project) or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md`.
Replace the default system prompt with `.pi.user/SYSTEM.md` (project-local user), `.pi/SYSTEM.md` (project), or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md` in the same locations.
---
@@ -325,7 +331,7 @@ Review this code for bugs, security issues, and performance problems.
Focus on: {{focus}}
```
Place in `~/.pi/agent/prompts/`, `.pi/prompts/`, or a [pi package](#pi-packages) to share with others. See [docs/prompt-templates.md](docs/prompt-templates.md).
Place in `~/.pi/agent/prompts/`, `.pi/prompts/`, `.pi.user/prompts/`, or a [pi package](#pi-packages) to share with others. See [docs/prompt-templates.md](docs/prompt-templates.md).
### Skills
@@ -341,7 +347,7 @@ Use this skill when the user asks about X.
2. Then that
```
Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md).
Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, `.pi.user/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md).
### Extensions
@@ -373,13 +379,13 @@ The default export can also be `async`. pi waits for async extension factories b
- Games while waiting (yes, Doom runs)
- ...anything you can dream up
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, `.pi.user/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).
### Themes
Built-in: `dark`, `light`. Themes hot-reload: modify the active theme file and pi immediately applies changes.
Place in `~/.pi/agent/themes/`, `.pi/themes/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md).
Place in `~/.pi/agent/themes/`, `.pi/themes/`, `.pi.user/themes/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md).
### Pi Packages
@@ -409,7 +415,7 @@ pi update npm:@foo/pi-tools # update one package
pi config # enable/disable extensions, skills, prompts, themes
```
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`), or `-l -u` for ignored project-local user installs (`.pi.user/git/`, `.pi.user/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Create a package by adding a `pi` key to `package.json`:
@@ -497,9 +503,9 @@ pi [options] [@files...] [messages...]
### Package Commands
```bash
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
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 update [source|self|pi] # Update pi and packages (skips pinned packages)
pi update --extensions # Update packages only
pi update --self # Update pi only
@@ -582,6 +588,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
| `--system-prompt <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 |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+3 -1
View File
@@ -4,7 +4,7 @@
Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.
> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global), `.pi/extensions/` (project-local), or `.pi.user/extensions/` (project-local user) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.
**Key capabilities:**
- **Custom tools** - Register tools the LLM can call via `pi.registerTool()`
@@ -117,6 +117,8 @@ Extensions are auto-discovered from:
| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
| `.pi/extensions/*.ts` | Project-local |
| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
| `.pi.user/extensions/*.ts` | Project-local user |
| `.pi.user/extensions/*/index.ts` | Project-local user (subdirectory) |
Additional paths via `settings.json`:
+4 -4
View File
@@ -38,7 +38,7 @@ pi update --extension npm:@foo/bar
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup.
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Use `-l -u` to write to ignored project-local user settings (`.pi.user/settings.json`). Project settings can be shared with your team, and pi installs any missing packages automatically on startup.
To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:
@@ -60,7 +60,7 @@ npm:pkg
- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`).
- User installs go under `~/.pi/agent/npm/`.
- Project installs go under `.pi/npm/`.
- Project installs go under `.pi/npm/`; project-local user installs go under `.pi.user/npm/`.
- Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`.
Example:
@@ -87,7 +87,7 @@ ssh://git@github.com/user/repo@v1
- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Cloned to `~/.pi/agent/git/<host>/<path>` (global), `.pi/git/<host>/<path>` (project), or `.pi.user/git/<host>/<path>` (project-local user).
- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
**SSH examples:**
@@ -219,7 +219,7 @@ Use `pi config` to enable or disable extensions, skills, prompt templates, and t
## Scope and Deduplication
Packages can appear in both global and project settings. If the same package appears in both, the project entry wins. Identity is determined by:
Packages can appear in global, project, and project-local user settings. If the same package appears in more than one scope, `.pi.user` wins over `.pi`, and project scopes win over global. Identity is determined by:
- npm: package name
- git: repository URL without ref
@@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam
Pi loads prompt templates from:
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Project: `.pi/prompts/*.md`, `.pi.user/prompts/*.md`
- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)
+14 -4
View File
@@ -1,14 +1,19 @@
# Settings
Pi uses JSON settings files with project settings overriding global settings.
Pi uses JSON settings files with project settings overriding global settings and `.pi.user` overriding shared project settings.
| Location | Scope |
|----------|-------|
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (current directory) |
| `.pi.user/settings.json` | Project-local user overrides (ignored by Git when Pi creates it) |
Edit directly or use `/settings` for common options.
## Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
## All Settings
### Model & Thinking
@@ -193,7 +198,7 @@ When multiple sources specify a session directory, precedence is `--session-dir`
These settings define where to load extensions, skills, prompts, and themes from.
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`; paths in `.pi.user/settings.json` resolve relative to `.pi.user`. Absolute paths and `~` are supported.
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
@@ -259,7 +264,7 @@ See [packages.md](packages.md) for package management details.
## Project Overrides
Project settings (`.pi/settings.json`) override global settings. Nested objects are merged:
Project settings (`.pi/settings.json`) override global settings. Project-local user settings (`.pi.user/settings.json`) override both. Nested objects are merged:
```json
// ~/.pi/agent/settings.json (global)
@@ -273,9 +278,14 @@ Project settings (`.pi/settings.json`) override global settings. Nested objects
"compaction": { "reserveTokens": 8192 }
}
// .pi.user/settings.json (project-local user)
{
"theme": "light"
}
// Result
{
"theme": "dark",
"theme": "light",
"compaction": { "enabled": true, "reserveTokens": 8192 }
}
```
+2 -1
View File
@@ -28,13 +28,14 @@ Pi loads skills from:
- `~/.agents/skills/`
- Project:
- `.pi/skills/`
- `.pi.user/skills/`
- `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)
- Packages: `skills/` directories or `pi.skills` entries in `package.json`
- Settings: `skills` array with files or directories
- CLI: `--skill <path>` (repeatable, additive even with `--no-skills`)
Discovery rules:
- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills
- In `~/.pi/agent/skills/`, `.pi/skills/`, and `.pi.user/skills/`, direct root `.md` files are discovered as individual skills
- In all skill locations, directories containing `SKILL.md` are discovered recursively
- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored
+1 -1
View File
@@ -20,7 +20,7 @@ Pi loads themes from:
- Built-in: `dark`, `light`
- Global: `~/.pi/agent/themes/*.json`
- Project: `.pi/themes/*.json`
- Project: `.pi/themes/*.json`, `.pi.user/themes/*.json`
- Packages: `themes/` directories or `pi.themes` entries in `package.json`
- Settings: `themes` array with files or directories
- CLI: `--theme <path>` (repeatable)
+11 -4
View File
@@ -51,6 +51,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/export [file]` | Export session to HTML |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files |
| `/trust [yes|no|reset]` | Configure whether `.pi` and `.pi.user` are trusted for this working directory |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |
@@ -105,10 +106,15 @@ Use context files for project conventions, commands, safety rules, and preferenc
Replace the default system prompt with:
- `.pi.user/SYSTEM.md` for project-local user overrides
- `.pi/SYSTEM.md` for a project
- `~/.pi/agent/SYSTEM.md` globally
Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location.
Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in those locations.
### Project Trust
Interactive startup asks before loading `.pi` or `.pi.user` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and missing/null asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
## Exporting and Sharing Sessions
@@ -127,9 +133,9 @@ pi [options] [@files...] [messages...]
### Package Commands
```bash
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
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 update [source|self|pi] # Update pi and packages; reconcile pinned git refs
pi update --extensions # Update packages only; reconcile pinned git refs
pi update --self # Update pi only
@@ -219,6 +225,7 @@ pi --no-extensions -e ./my-extension.ts
| `--system-prompt <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 |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+8 -4
View File
@@ -46,6 +46,7 @@ export interface Args {
listModels?: string | true;
offline?: boolean;
verbose?: boolean;
force?: boolean;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */
@@ -176,6 +177,8 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg === "--verbose") {
result.verbose = true;
} else if (arg === "--force" || arg === "-f") {
result.force = true;
} else if (arg === "--offline") {
result.offline = true;
} else if (arg.startsWith("@")) {
@@ -221,11 +224,11 @@ ${chalk.bold("Usage:")}
${APP_NAME} [options] [@files...] [messages...]
${chalk.bold("Commands:")}
${APP_NAME} install <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} 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} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list List installed extensions from settings
${APP_NAME} list [--force] List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
@@ -266,6 +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
--offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help
--version, -v Show version number
+1
View File
@@ -465,6 +465,7 @@ export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent
export const APP_NAME: string = piConfigName || "pi";
export const APP_TITLE: string = piConfigName ? APP_NAME : "π";
export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi";
export const PROJECT_USER_CONFIG_DIR_NAME = `${CONFIG_DIR_NAME}.user`;
export const VERSION: string = pkg.version || "0.0.0";
// e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR
+218 -162
View File
@@ -27,11 +27,12 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import { ensureIgnoredProjectUserConfigDir } from "./project-user-config.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
const NETWORK_TIMEOUT_MS = 10000;
@@ -44,9 +45,12 @@ function isOfflineModeEnabled(): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
export type SettingsSourceScope = "user" | "project" | "projectUser";
export type ResourceMetadataScope = SettingsSourceScope | "temporary";
export interface PathMetadata {
source: string;
scope: SourceScope;
scope: ResourceMetadataScope;
origin: "package" | "top-level";
baseDir?: string;
}
@@ -79,32 +83,32 @@ export interface PackageUpdate {
source: string;
displayName: string;
type: "npm" | "git";
scope: Exclude<SourceScope, "temporary">;
scope: InstalledSourceScope;
}
export interface ConfiguredPackage {
source: string;
scope: "user" | "project";
scope: InstalledSourceScope;
filtered: boolean;
installedPath?: string;
}
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
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>;
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>;
update(source?: string): Promise<void>;
listConfiguredPackages(): ConfiguredPackage[];
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
addSourceToSettings(source: string, options?: { local?: boolean }): boolean;
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean;
addSourceToSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean;
removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean;
setProgressCallback(callback: ProgressCallback | undefined): void;
getInstalledPath(source: string, scope: "user" | "project"): string | undefined;
getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined;
}
interface PackageManagerOptions {
@@ -113,7 +117,7 @@ interface PackageManagerOptions {
settingsManager: SettingsManager;
}
type SourceScope = "user" | "project" | "temporary";
type SourceScope = SettingsSourceScope | "temporary";
type NpmSource = {
type: "npm";
@@ -129,7 +133,7 @@ type LocalSource = {
type ParsedSource = NpmSource | GitSource | LocalSource;
type InstalledSourceScope = Exclude<SourceScope, "temporary">;
export type InstalledSourceScope = SettingsSourceScope;
interface ConfiguredUpdateSource {
source: string;
@@ -164,15 +168,17 @@ interface ResourceAccumulator {
* name-collision resolution ("first wins") produces the correct outcome.
*
* Precedence (highest to lowest):
* 0 project + settings entry (source: "local", scope: "project")
* 1 project + auto-discovered (source: "auto", scope: "project")
* 2 user + settings entry (source: "local", scope: "user")
* 3 user + auto-discovered (source: "auto", scope: "user")
* 4 package resource (origin: "package")
* 0 .pi.user + settings entry
* 1 .pi.user + auto-discovered
* 2 .pi + settings entry
* 3 .pi + auto-discovered
* 4 user + settings entry
* 5 user + auto-discovered
* 6 package resource
*/
function resourcePrecedenceRank(m: PathMetadata): number {
if (m.origin === "package") return 4;
const scopeBase = m.scope === "project" ? 0 : 2;
if (m.origin === "package") return 6;
const scopeBase = m.scope === "projectUser" ? 0 : m.scope === "project" ? 2 : m.scope === "user" ? 4 : 6;
return scopeBase + (m.source === "local" ? 0 : 1);
}
@@ -772,10 +778,36 @@ export class DefaultPackageManager implements PackageManager {
this.progressCallback = callback;
}
addSourceToSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
private getScopeForOptions(options?: { local?: boolean; localUser?: boolean }): InstalledSourceScope {
if (options?.localUser) {
return "projectUser";
}
return options?.local ? "project" : "user";
}
private getSettingsForScope(scope: InstalledSourceScope) {
if (scope === "projectUser") {
return this.settingsManager.getProjectUserSettings();
}
if (scope === "project") {
return this.settingsManager.getProjectSettings();
}
return this.settingsManager.getGlobalSettings();
}
private setPackagesForScope(scope: InstalledSourceScope, packages: PackageSource[]): void {
if (scope === "projectUser") {
this.settingsManager.setProjectUserPackages(packages);
} else if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
} else {
this.settingsManager.setPackages(packages);
}
}
addSourceToSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean {
const scope = this.getScopeForOptions(options);
const currentSettings = this.getSettingsForScope(scope);
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
@@ -787,41 +819,28 @@ export class DefaultPackageManager implements PackageManager {
const nextPackages = [...currentPackages];
nextPackages[matchIndex] =
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
this.setPackagesForScope(scope, nextPackages);
return true;
}
const nextPackages = [...currentPackages, normalizedSource];
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
this.setPackagesForScope(scope, nextPackages);
return true;
}
removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean {
const scope: SourceScope = options?.local ? "project" : "user";
const currentSettings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
removeSourceFromSettings(source: string, options?: { local?: boolean; localUser?: boolean }): boolean {
const scope = this.getScopeForOptions(options);
const currentSettings = this.getSettingsForScope(scope);
const currentPackages = currentSettings.packages ?? [];
const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope));
const changed = nextPackages.length !== currentPackages.length;
if (!changed) {
return false;
}
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
this.setPackagesForScope(scope, nextPackages);
return true;
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
getInstalledPath(source: string, scope: InstalledSourceScope): string | undefined {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope);
@@ -863,39 +882,42 @@ export class DefaultPackageManager implements PackageManager {
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
// Collect all packages with scope (project first so cwd resources win collisions)
// Collect all packages with scope (project-local user first so it wins collisions)
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
allPackages.push({ pkg, scope: layer.scope });
}
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
// Dedupe: project scope wins over global for same package identity
// Dedupe: earlier scopes win for the same package identity.
const packageSources = this.dedupePackages(allPackages);
await this.resolvePackageSources(packageSources, accumulator, onMissing);
const globalBaseDir = this.agentDir;
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []) as string[];
const projectEntries = (projectSettings[resourceType] ?? []) as string[];
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: "project",
origin: "top-level",
},
projectBaseDir,
);
for (const layer of projectLayers) {
const projectEntries = (layer.settings[resourceType] ?? []) as string[];
this.resolveLocalEntries(
projectEntries,
resourceType,
target,
{
source: "local",
scope: layer.scope,
origin: "top-level",
},
this.getBaseDirForScope(layer.scope),
);
}
this.resolveLocalEntries(
globalEntries,
resourceType,
@@ -909,7 +931,7 @@ export class DefaultPackageManager implements PackageManager {
);
}
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
this.addAutoDiscoveredResources(accumulator, globalSettings, projectLayers, globalBaseDir);
return this.toResolvedPaths(accumulator);
}
@@ -927,7 +949,7 @@ export class DefaultPackageManager implements PackageManager {
listConfiguredPackages(): ConfiguredPackage[] {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const configuredPackages: ConfiguredPackage[] = [];
for (const pkg of globalSettings.packages ?? []) {
@@ -940,22 +962,24 @@ export class DefaultPackageManager implements PackageManager {
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: layer.scope,
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, layer.scope),
});
}
}
return configuredPackages;
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
async install(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
const scope: SourceScope = this.getScopeForOptions(options);
await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
@@ -976,14 +1000,14 @@ export class DefaultPackageManager implements PackageManager {
});
}
async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
async installAndPersist(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
async remove(source: string, options?: { local?: boolean; localUser?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
const scope: SourceScope = this.getScopeForOptions(options);
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
@@ -1000,14 +1024,14 @@ export class DefaultPackageManager implements PackageManager {
});
}
async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
async removeAndPersist(source: string, options?: { local?: boolean; localUser?: 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 projectSettings = this.settingsManager.getProjectSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const identity = source ? this.getPackageIdentity(source) : undefined;
let matched = false;
const updateSources: ConfiguredUpdateSource[] = [];
@@ -1018,18 +1042,20 @@ export class DefaultPackageManager implements PackageManager {
matched = true;
updateSources.push({ source: sourceStr, scope: "user" });
}
for (const pkg of projectSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "project" });
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, layer.scope) !== identity) continue;
matched = true;
updateSources.push({ source: sourceStr, scope: layer.scope });
}
}
if (source && !matched) {
throw new Error(
this.buildNoMatchingPackageMessage(source, [
...(globalSettings.packages ?? []),
...(projectSettings.packages ?? []),
...projectLayers.flatMap((layer) => layer.settings.packages ?? []),
]),
);
}
@@ -1063,25 +1089,21 @@ export class DefaultPackageManager implements PackageManager {
shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope),
}));
const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);
const userNpmUpdates: NpmUpdateTarget[] = [];
const projectNpmUpdates: NpmUpdateTarget[] = [];
const npmUpdatesByScope = new Map<InstalledSourceScope, NpmUpdateTarget[]>();
for (const result of npmCheckResults) {
if (!result.shouldUpdate) {
continue;
}
if (result.entry.scope === "user") {
userNpmUpdates.push(result.entry);
} else {
projectNpmUpdates.push(result.entry);
}
const updates = npmUpdatesByScope.get(result.entry.scope) ?? [];
updates.push(result.entry);
npmUpdatesByScope.set(result.entry.scope, updates);
}
const tasks: Promise<void>[] = [];
if (userNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));
}
if (projectNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
for (const [scope, updates] of npmUpdatesByScope.entries()) {
if (updates.length > 0) {
tasks.push(this.updateNpmBatch(updates, scope));
}
}
if (gitCandidates.length > 0) {
const gitTasks = gitCandidates.map(
@@ -1138,10 +1160,12 @@ export class DefaultPackageManager implements PackageManager {
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const projectLayers = this.settingsManager.getProjectSettingsLayers();
const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
for (const layer of projectLayers) {
for (const pkg of layer.settings.packages ?? []) {
allPackages.push({ pkg, scope: layer.scope });
}
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
@@ -1149,10 +1173,7 @@ export class DefaultPackageManager implements PackageManager {
const packageSources = this.dedupePackages(allPackages);
const checks = packageSources
.filter(
(entry): entry is { pkg: PackageSource; scope: Exclude<SourceScope, "temporary"> } =>
entry.scope !== "temporary",
)
.filter((entry): entry is { pkg: PackageSource; scope: InstalledSourceScope } => 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);
@@ -1206,7 +1227,11 @@ export class DefaultPackageManager implements PackageManager {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const parsed = this.parseSource(sourceStr);
const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
const metadata: PathMetadata = {
source: sourceStr,
scope,
origin: "package",
};
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
@@ -1630,8 +1655,8 @@ export class DefaultPackageManager implements PackageManager {
}
/**
* Dedupe packages: if same package identity appears in both global and project,
* keep only the project one (project wins).
* Dedupe packages by identity. Callers pass sources in precedence order, so the
* first occurrence wins.
*/
private dedupePackages(
packages: Array<{ pkg: PackageSource; scope: SourceScope }>,
@@ -1642,15 +1667,10 @@ export class DefaultPackageManager implements PackageManager {
const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const identity = this.getPackageIdentity(sourceStr, entry.scope);
const existing = seen.get(identity);
if (!existing) {
seen.set(identity, entry);
} else if (entry.scope === "project" && existing.scope === "user") {
// Project wins over user
if (!seen.has(identity)) {
seen.set(identity, entry);
}
// If existing is project and new is global, keep existing (project)
// If both are same scope, keep first one
// Callers pass sources in precedence order. Keep the first matching identity.
}
return Array.from(seen.values());
@@ -1728,6 +1748,9 @@ export class DefaultPackageManager implements PackageManager {
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "projectUser") {
this.ensureProjectUserRootIgnore();
}
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot));
@@ -1746,6 +1769,9 @@ export class DefaultPackageManager implements PackageManager {
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
if (scope === "projectUser") {
this.ensureProjectUserRootIgnore();
}
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
if (source.ref) {
@@ -1881,10 +1907,17 @@ export class DefaultPackageManager implements PackageManager {
}
}
private ensureProjectUserRootIgnore(): void {
ensureIgnoredProjectUserConfigDir(join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME));
}
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
if (temporary) {
return this.getTemporaryDir("npm");
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "npm");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
@@ -1925,6 +1958,9 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
@@ -1952,6 +1988,9 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "temporary") {
return this.getTemporaryDir(`git-${source.host}`, source.path);
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "git", source.host, source.path);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
}
@@ -1962,6 +2001,9 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "temporary") {
return undefined;
}
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "git");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git");
}
@@ -1977,6 +2019,9 @@ export class DefaultPackageManager implements PackageManager {
}
private getBaseDirForScope(scope: SourceScope): string {
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME);
}
@@ -2196,9 +2241,8 @@ export class DefaultPackageManager implements PackageManager {
private addAutoDiscoveredResources(
accumulator: ResourceAccumulator,
globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>,
projectSettings: ReturnType<SettingsManager["getProjectSettings"]>,
projectLayers: ReturnType<SettingsManager["getProjectSettingsLayers"]>,
globalBaseDir: string,
projectBaseDir: string,
): void {
const userMetadata: PathMetadata = {
source: "auto",
@@ -2206,11 +2250,11 @@ export class DefaultPackageManager implements PackageManager {
origin: "top-level",
baseDir: globalBaseDir,
};
const projectMetadata: PathMetadata = {
const projectAgentMetadata: PathMetadata = {
source: "auto",
scope: "project",
origin: "top-level",
baseDir: projectBaseDir,
baseDir: join(this.cwd, CONFIG_DIR_NAME),
};
const userOverrides = {
@@ -2219,11 +2263,11 @@ export class DefaultPackageManager implements PackageManager {
prompts: (globalSettings.prompts ?? []) as string[],
themes: (globalSettings.themes ?? []) as string[],
};
const projectOverrides = {
extensions: (projectSettings.extensions ?? []) as string[],
skills: (projectSettings.skills ?? []) as string[],
prompts: (projectSettings.prompts ?? []) as string[],
themes: (projectSettings.themes ?? []) as string[],
const projectSkillOverrides = [...projectLayers]
.reverse()
.reduce<string[]>((skills, layer) => layer.settings.skills ?? skills, []);
const projectAgentOverrides = {
skills: projectSkillOverrides,
};
const userDirs = {
@@ -2232,12 +2276,6 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
@@ -2257,55 +2295,73 @@ export class DefaultPackageManager implements PackageManager {
}
};
// Project extensions from .pi/
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
for (const layer of projectLayers) {
const projectBaseDir = this.getBaseDirForScope(layer.scope);
const projectMetadata: PathMetadata = {
source: "auto",
scope: layer.scope,
origin: "top-level",
baseDir: projectBaseDir,
};
const projectOverrides = {
extensions: (layer.settings.extensions ?? []) as string[],
skills: (layer.settings.skills ?? []) as string[],
prompts: (layer.settings.prompts ?? []) as string[],
themes: (layer.settings.themes ?? []) as string[],
};
const projectDirs = {
extensions: join(projectBaseDir, "extensions"),
skills: join(projectBaseDir, "skills"),
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
// Project skills from .pi/
addResources(
"skills",
collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
addResources(
"extensions",
collectAutoExtensionEntries(projectDirs.extensions),
projectMetadata,
projectOverrides.extensions,
projectBaseDir,
);
addResources(
"skills",
collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata,
projectOverrides.skills,
projectBaseDir,
);
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
projectOverrides.themes,
projectBaseDir,
);
}
// Project skills from .agents/ (each with its own baseDir)
for (const agentsSkillsDir of projectAgentsSkillDirs) {
const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
const agentsMetadata: PathMetadata = {
...projectMetadata,
...projectAgentMetadata,
baseDir: agentsBaseDir,
};
addResources(
"skills",
collectAutoSkillEntries(agentsSkillsDir, "agents"),
agentsMetadata,
projectOverrides.skills,
projectAgentOverrides.skills,
agentsBaseDir,
);
}
addResources(
"prompts",
collectAutoPromptEntries(projectDirs.prompts),
projectMetadata,
projectOverrides.prompts,
projectBaseDir,
);
addResources(
"themes",
collectAutoThemeEntries(projectDirs.themes),
projectMetadata,
projectOverrides.themes,
projectBaseDir,
);
// User extensions from ~/.pi/agent/
addResources(
"extensions",
@@ -0,0 +1,14 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export const PROJECT_USER_GITIGNORE_CONTENT = "*\n.*\n";
export function ensureIgnoredProjectUserConfigDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const ignorePath = join(dir, ".gitignore");
if (!existsSync(ignorePath)) {
writeFileSync(ignorePath, PROJECT_USER_GITIGNORE_CONTENT, "utf-8");
}
}
@@ -1,7 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME } from "../config.ts";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
@@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
import { DefaultPackageManager, type MissingSourceAction, type PathMetadata } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts";
@@ -145,6 +145,7 @@ export interface DefaultResourceLoaderOptions {
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
onMissingPackage?: (source: string) => Promise<MissingSourceAction>;
systemPromptOverride?: (base: string | undefined) => string | undefined;
appendSystemPromptOverride?: (base: string[]) => string[];
}
@@ -183,6 +184,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
private onMissingPackage?: (source: string) => Promise<MissingSourceAction>;
private systemPromptOverride?: (base: string | undefined) => string | undefined;
private appendSystemPromptOverride?: (base: string[]) => string[];
@@ -230,6 +232,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.promptsOverride = options.promptsOverride;
this.themesOverride = options.themesOverride;
this.agentsFilesOverride = options.agentsFilesOverride;
this.onMissingPackage = options.onMissingPackage;
this.systemPromptOverride = options.systemPromptOverride;
this.appendSystemPromptOverride = options.appendSystemPromptOverride;
@@ -320,7 +323,7 @@ export class DefaultResourceLoader implements ResourceLoader {
async reload(): Promise<void> {
await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve();
const resolvedPaths = await this.packageManager.resolve(this.onMissingPackage);
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
});
@@ -649,6 +652,10 @@ export class DefaultResourceLoader implements ResourceLoader {
join(this.agentDir, "extensions"),
];
const projectRoots = [
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "skills"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "prompts"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "themes"),
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "extensions"),
join(this.cwd, CONFIG_DIR_NAME, "skills"),
join(this.cwd, CONFIG_DIR_NAME, "prompts"),
join(this.cwd, CONFIG_DIR_NAME, "themes"),
@@ -851,9 +858,13 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private discoverSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
const projectPaths = this.settingsManager.isProjectConfigTrusted()
? [join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "SYSTEM.md"), join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md")]
: [];
for (const projectPath of projectPaths) {
if (existsSync(projectPath)) {
return projectPath;
}
}
const globalPath = join(this.agentDir, "SYSTEM.md");
@@ -865,9 +876,16 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private discoverAppendSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
const projectPaths = this.settingsManager.isProjectConfigTrusted()
? [
join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME, "APPEND_SYSTEM.md"),
join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"),
]
: [];
for (const projectPath of projectPaths) {
if (existsSync(projectPath)) {
return projectPath;
}
}
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
@@ -2,9 +2,10 @@ import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { CONFIG_DIR_NAME, getAgentDir, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
import { ensureIgnoredProjectUserConfigDir } from "./project-user-config.ts";
export interface CompactionSettings {
enabled?: boolean; // default: true
@@ -157,10 +158,22 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde
return undefined;
}
export type SettingsScope = "global" | "project";
export type SettingsScope = "global" | "project" | "projectUser";
export type ProjectSettingsScope = "project" | "projectUser";
export interface SettingsManagerCreateOptions {
projectConfigTrusted?: boolean;
}
export interface ProjectSettingsLayer {
scope: ProjectSettingsScope;
settings: Settings;
}
export interface SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
setProjectConfigTrusted?(trusted: boolean): void;
isProjectConfigTrusted?(): boolean;
}
export interface SettingsError {
@@ -171,12 +184,24 @@ export interface SettingsError {
export class FileSettingsStorage implements SettingsStorage {
private globalSettingsPath: string;
private projectSettingsPath: string;
private projectUserSettingsPath: string;
private projectConfigTrusted: boolean;
constructor(cwd: string, agentDir: string) {
constructor(cwd: string, agentDir: string, options: SettingsManagerCreateOptions = {}) {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
this.projectUserSettingsPath = join(resolvedCwd, PROJECT_USER_CONFIG_DIR_NAME, "settings.json");
this.projectConfigTrusted = options.projectConfigTrusted ?? true;
}
setProjectConfigTrusted(trusted: boolean): void {
this.projectConfigTrusted = trusted;
}
isProjectConfigTrusted(): boolean {
return this.projectConfigTrusted;
}
private acquireLockSyncWithRetry(path: string): () => void {
@@ -206,8 +231,24 @@ export class FileSettingsStorage implements SettingsStorage {
throw (lastError as Error) ?? new Error("Failed to acquire settings lock");
}
private getSettingsPath(scope: SettingsScope): string {
switch (scope) {
case "global":
return this.globalSettingsPath;
case "project":
return this.projectSettingsPath;
case "projectUser":
return this.projectUserSettingsPath;
}
}
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) {
fn(undefined);
return;
}
const path = this.getSettingsPath(scope);
const dir = dirname(path);
let release: (() => void) | undefined;
@@ -221,7 +262,9 @@ export class FileSettingsStorage implements SettingsStorage {
const next = fn(current);
if (next !== undefined) {
// Only create directory when we actually need to write
if (!existsSync(dir)) {
if (scope === "projectUser") {
ensureIgnoredProjectUserConfigDir(dir);
} else if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (!release) {
@@ -240,15 +283,32 @@ export class FileSettingsStorage implements SettingsStorage {
export class InMemorySettingsStorage implements SettingsStorage {
private global: string | undefined;
private project: string | undefined;
private projectUser: string | undefined;
private projectConfigTrusted = true;
setProjectConfigTrusted(trusted: boolean): void {
this.projectConfigTrusted = trusted;
}
isProjectConfigTrusted(): boolean {
return this.projectConfigTrusted;
}
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
const current = scope === "global" ? this.global : this.project;
if ((scope === "project" || scope === "projectUser") && !this.projectConfigTrusted) {
fn(undefined);
return;
}
const current = scope === "global" ? this.global : scope === "project" ? this.project : this.projectUser;
const next = fn(current);
if (next !== undefined) {
if (scope === "global") {
this.global = next;
} else {
} else if (scope === "project") {
this.project = next;
} else {
this.projectUser = next;
}
}
}
@@ -258,13 +318,18 @@ export class SettingsManager {
private storage: SettingsStorage;
private globalSettings: Settings;
private projectSettings: Settings;
private projectUserSettings: Settings;
private settings: Settings;
private projectConfigTrusted: boolean;
private modifiedFields = new Set<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[];
@@ -272,29 +337,53 @@ export class SettingsManager {
storage: SettingsStorage,
initialGlobal: Settings,
initialProject: Settings,
initialProjectUser: Settings,
globalLoadError: Error | null = null,
projectLoadError: Error | null = null,
projectUserLoadError: Error | null = null,
initialErrors: SettingsError[] = [],
projectConfigTrusted = true,
) {
this.storage = storage;
this.globalSettings = initialGlobal;
this.projectSettings = initialProject;
this.projectUserSettings = initialProjectUser;
this.projectConfigTrusted = projectConfigTrusted;
this.globalSettingsLoadError = globalLoadError;
this.projectSettingsLoadError = projectLoadError;
this.projectUserSettingsLoadError = projectUserLoadError;
this.errors = [...initialErrors];
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
this.settings = this.mergeAllSettings();
}
private mergeProjectSettings(): Settings {
return deepMergeSettings(this.projectSettings, this.projectUserSettings);
}
private mergeAllSettings(): Settings {
return deepMergeSettings(this.globalSettings, this.mergeProjectSettings());
}
private rebuildSettings(): void {
this.settings = this.mergeAllSettings();
}
/** Create a SettingsManager that loads from files */
static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager {
const storage = new FileSettingsStorage(cwd, agentDir);
static create(
cwd: string,
agentDir: string = getAgentDir(),
options: SettingsManagerCreateOptions = {},
): SettingsManager {
const storage = new FileSettingsStorage(cwd, agentDir, options);
return SettingsManager.fromStorage(storage);
}
/** Create a SettingsManager from an arbitrary storage backend */
static fromStorage(storage: SettingsStorage): SettingsManager {
const projectConfigTrusted = storage.isProjectConfigTrusted?.() ?? true;
const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project");
const projectUserLoad = SettingsManager.tryLoadFromStorage(storage, "projectUser");
const initialErrors: SettingsError[] = [];
if (globalLoad.error) {
initialErrors.push({ scope: "global", error: globalLoad.error });
@@ -302,14 +391,20 @@ export class SettingsManager {
if (projectLoad.error) {
initialErrors.push({ scope: "project", error: projectLoad.error });
}
if (projectUserLoad.error) {
initialErrors.push({ scope: "projectUser", error: projectUserLoad.error });
}
return new SettingsManager(
storage,
globalLoad.settings,
projectLoad.settings,
projectUserLoad.settings,
globalLoad.error,
projectLoad.error,
projectUserLoad.error,
initialErrors,
projectConfigTrusted,
);
}
@@ -416,6 +511,40 @@ export class SettingsManager {
return structuredClone(this.projectSettings);
}
getProjectUserSettings(): Settings {
return structuredClone(this.projectUserSettings);
}
getProjectSettingsLayers(): ProjectSettingsLayer[] {
if (!this.projectConfigTrusted) {
return [];
}
return [
{ scope: "projectUser", settings: structuredClone(this.projectUserSettings) },
{ scope: "project", settings: structuredClone(this.projectSettings) },
];
}
isProjectConfigTrusted(): boolean {
return this.projectConfigTrusted;
}
setProjectConfigTrusted(trusted: boolean): void {
this.projectConfigTrusted = trusted;
this.storage.setProjectConfigTrusted?.(trusted);
if (!trusted) {
this.projectSettings = {};
this.projectUserSettings = {};
this.projectSettingsLoadError = null;
this.projectUserSettingsLoadError = null;
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
this.rebuildSettings();
}
}
async reload(): Promise<void> {
await this.writeQueue;
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
@@ -431,6 +560,8 @@ export class SettingsManager {
this.modifiedNestedFields.clear();
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project");
if (!projectLoad.error) {
@@ -441,7 +572,16 @@ export class SettingsManager {
this.recordError("project", projectLoad.error);
}
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
const projectUserLoad = SettingsManager.tryLoadFromStorage(this.storage, "projectUser");
if (!projectUserLoad.error) {
this.projectUserSettings = projectUserLoad.settings;
this.projectUserSettingsLoadError = null;
} else {
this.projectUserSettingsLoadError = projectUserLoad.error;
this.recordError("projectUser", projectUserLoad.error);
}
this.rebuildSettings();
}
/** Apply additional overrides on top of current settings */
@@ -471,6 +611,17 @@ export class SettingsManager {
}
}
/** Mark a .pi.user field as modified during this session */
private markProjectUserModified(field: keyof Settings, nestedKey?: string): void {
this.modifiedProjectUserFields.add(field);
if (nestedKey) {
if (!this.modifiedProjectUserNestedFields.has(field)) {
this.modifiedProjectUserNestedFields.set(field, new Set());
}
this.modifiedProjectUserNestedFields.get(field)!.add(nestedKey);
}
}
private recordError(scope: SettingsScope, error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push({ scope, error: normalizedError });
@@ -482,9 +633,14 @@ export class SettingsManager {
this.modifiedNestedFields.clear();
return;
}
if (scope === "project") {
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
return;
}
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
this.modifiedProjectUserFields.clear();
this.modifiedProjectUserNestedFields.clear();
}
private enqueueWrite(scope: SettingsScope, task: () => void): void {
@@ -538,7 +694,7 @@ export class SettingsManager {
}
private save(): void {
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
this.rebuildSettings();
if (this.globalSettingsLoadError) {
return;
@@ -555,7 +711,7 @@ export class SettingsManager {
private saveProjectSettings(settings: Settings): void {
this.projectSettings = structuredClone(settings);
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
this.rebuildSettings();
if (this.projectSettingsLoadError) {
return;
@@ -569,6 +725,22 @@ export class SettingsManager {
});
}
private saveProjectUserSettings(settings: Settings): void {
this.projectUserSettings = structuredClone(settings);
this.rebuildSettings();
if (this.projectUserSettingsLoadError) {
return;
}
const snapshotProjectUserSettings = structuredClone(this.projectUserSettings);
const modifiedFields = new Set(this.modifiedProjectUserFields);
const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectUserNestedFields);
this.enqueueWrite("projectUser", () => {
this.persistScopedSettings("projectUser", snapshotProjectUserSettings, modifiedFields, modifiedNestedFields);
});
}
async flush(): Promise<void> {
await this.writeQueue;
}
@@ -845,6 +1017,13 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserPackages(packages: PackageSource[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.packages = packages;
this.markProjectUserModified("packages");
this.saveProjectUserSettings(projectUserSettings);
}
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
@@ -862,6 +1041,13 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserExtensionPaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.extensions = paths;
this.markProjectUserModified("extensions");
this.saveProjectUserSettings(projectUserSettings);
}
getSkillPaths(): string[] {
return [...(this.settings.skills ?? [])];
}
@@ -879,6 +1065,13 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserSkillPaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.skills = paths;
this.markProjectUserModified("skills");
this.saveProjectUserSettings(projectUserSettings);
}
getPromptTemplatePaths(): string[] {
return [...(this.settings.prompts ?? [])];
}
@@ -896,6 +1089,13 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserPromptTemplatePaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.prompts = paths;
this.markProjectUserModified("prompts");
this.saveProjectUserSettings(projectUserSettings);
}
getThemePaths(): string[] {
return [...(this.settings.themes ?? [])];
}
@@ -913,6 +1113,13 @@ export class SettingsManager {
this.saveProjectSettings(projectSettings);
}
setProjectUserThemePaths(paths: string[]): void {
const projectUserSettings = structuredClone(this.projectUserSettings);
projectUserSettings.themes = paths;
this.markProjectUserModified("themes");
this.saveProjectUserSettings(projectUserSettings);
}
getEnableSkillCommands(): boolean {
return this.settings.enableSkillCommands ?? true;
}
@@ -36,5 +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: "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,
scope: metadata.scope === "projectUser" ? "project" : metadata.scope,
origin: metadata.origin,
baseDir: metadata.baseDir,
};
@@ -0,0 +1,92 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../config.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
export type ProjectTrustDecision = boolean | null;
type TrustFile = Record<string, boolean | null | undefined>;
function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
function readTrustFile(path: string): TrustFile {
if (!existsSync(path)) {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf-8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read trust store ${path}: ${message}`);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`Invalid trust store ${path}: expected an object`);
}
const data: TrustFile = {};
for (const [key, value] of Object.entries(parsed)) {
if (value !== true && value !== false && value !== null) {
throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`);
}
data[key] = value;
}
return data;
}
function writeTrustFile(path: string, data: TrustFile): void {
const sorted: TrustFile = {};
for (const key of Object.keys(data).sort()) {
const value = data[key];
if (value === true || value === false || value === null) {
sorted[key] = value;
}
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
}
export function hasProjectConfig(cwd: string): boolean {
const resolvedCwd = resolvePath(cwd);
return existsSync(join(resolvedCwd, CONFIG_DIR_NAME)) || existsSync(join(resolvedCwd, PROJECT_USER_CONFIG_DIR_NAME));
}
export class ProjectTrustStore {
private trustPath: string;
constructor(agentDir: string) {
this.trustPath = join(resolvePath(agentDir), "trust.json");
}
get(cwd: string): ProjectTrustDecision {
const data = readTrustFile(this.trustPath);
const value = data[normalizeCwd(cwd)];
return value === true || value === false ? value : null;
}
set(cwd: string, decision: ProjectTrustDecision): void {
const trustDir = dirname(this.trustPath);
mkdirSync(trustDir, { recursive: true });
let release: (() => void) | undefined;
try {
// Lock before reading or creating trust.json so malformed content cannot be
// silently replaced by a concurrent or follow-up write.
release = lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${this.trustPath}.lock` });
const data = readTrustFile(this.trustPath);
const key = normalizeCwd(cwd);
if (decision === null) {
delete data[key];
} else {
data[key] = decision;
}
writeTrustFile(this.trustPath, data);
} finally {
release?.();
}
}
}
+6
View File
@@ -152,12 +152,15 @@ export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"
export { convertToLlm } from "./core/messages.ts";
export { ModelRegistry } from "./core/model-registry.ts";
export type {
InstalledSourceScope,
PackageManager,
PathMetadata,
ProgressCallback,
ProgressEvent,
ResolvedPaths,
ResolvedResource,
ResourceMetadataScope,
SettingsSourceScope,
} from "./core/package-manager.ts";
export { DefaultPackageManager } from "./core/package-manager.ts";
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts";
@@ -217,8 +220,11 @@ export {
type CompactionSettings,
type ImageSettings,
type PackageSource,
type ProjectSettingsLayer,
type ProjectSettingsScope,
type RetrySettings,
SettingsManager,
type SettingsManagerCreateOptions,
} from "./core/settings-manager.ts";
// Skills
export {
+179 -20
View File
@@ -40,12 +40,17 @@ import {
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import {
handleConfigCommand,
handlePackageCommand,
packageCommandForcesProjectConfigTrust,
} from "./package-manager-cli.ts";
import { canonicalizePath, isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
/**
@@ -436,10 +441,25 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
function getSessionTrustOverrideKey(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
function isPackageCommandArg(arg: string | undefined): boolean {
return arg === "install" || arg === "remove" || arg === "uninstall" || arg === "update" || arg === "list";
}
function hasForceFlag(args: string[]): boolean {
return args.includes("--force") || args.includes("-f");
}
async function showStartupSelector<T>(
settingsManager: SettingsManager,
): Promise<string | undefined> {
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
// Startup prompts run before resource loading. Themes from packages, --themes,
// and project config are unavailable here; built-ins and user theme files work.
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
@@ -448,7 +468,7 @@ async function promptForMissingSessionCwd(
ui.setClearOnShrink(settingsManager.getClearOnShrink());
let settled = false;
const finish = (result: string | undefined) => {
const finish = (result: T | undefined) => {
if (settled) {
return;
}
@@ -458,9 +478,9 @@ async function promptForMissingSessionCwd(
};
const selector = new ExtensionSelectorComponent(
formatMissingSessionCwdPrompt(issue),
["Continue", "Cancel"],
(option) => finish(option === "Continue" ? issue.fallbackCwd : undefined),
title,
options.map((option) => option.label),
(option) => finish(options.find((entry) => entry.label === option)?.value),
() => finish(undefined),
{ tui: ui },
);
@@ -470,6 +490,71 @@ async function promptForMissingSessionCwd(
});
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
settingsManager: SettingsManager,
): Promise<string | undefined> {
return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [
{ label: "Continue", value: issue.fallbackCwd },
{ label: "Cancel", value: undefined },
]);
}
interface ProjectTrustPromptResult {
trusted: boolean;
remember: boolean;
}
async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise<ProjectTrustPromptResult> {
const selected = await showStartupSelector(
settingsManager,
`Trust project configuration?\nLoad .pi and .pi.user from ${cwd}?\nWarning: Project extensions can execute code.`,
[
{ label: "Yes (remember)", value: { trusted: true, remember: true } },
{ label: "Yes (this session)", value: { trusted: true, remember: false } },
{ label: "No (remember)", value: { trusted: false, remember: true } },
{ label: "No (this session)", value: { trusted: false, remember: false } },
],
);
return selected ?? { trusted: false, remember: false };
}
interface ProjectTrustResolution {
trusted: boolean;
sessionOverride?: boolean;
}
async function resolveProjectConfigTrusted(options: {
cwd: string;
agentDir: string;
sessionTrustOverride: boolean | undefined;
appMode: AppMode;
settingsManagerForPrompt: SettingsManager;
}): Promise<ProjectTrustResolution> {
if (options.sessionTrustOverride !== undefined) {
return { trusted: options.sessionTrustOverride };
}
if (!hasProjectConfig(options.cwd)) {
return { trusted: false };
}
const trustStore = new ProjectTrustStore(options.agentDir);
const decision = trustStore.get(options.cwd);
if (decision !== null) {
return { trusted: decision };
}
if (options.appMode !== "interactive") {
return { trusted: false };
}
const result = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt);
if (result.remember) {
trustStore.set(options.cwd, result.trusted);
return { trusted: result.trusted };
}
return { trusted: result.trusted, sessionOverride: result.trusted };
}
export interface MainOptions {
extensionFactories?: ExtensionFactory[];
}
@@ -486,12 +571,27 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
if (await handlePackageCommand(args)) {
return;
}
if (await handleConfigCommand(args)) {
return;
if (isPackageCommandArg(args[0]) || args[0] === "config") {
const cwd = process.cwd();
const agentDir = getAgentDir();
const projectConfigExists = hasProjectConfig(cwd);
const forceProjectConfigTrusted =
args[0] === "config" ? hasForceFlag(args) : packageCommandForcesProjectConfigTrust(args);
const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
const projectTrustResolution = await resolveProjectConfigTrusted({
cwd,
agentDir,
sessionTrustOverride: forceProjectConfigTrusted ? true : undefined,
appMode: process.stdin.isTTY ? "interactive" : "print",
settingsManagerForPrompt: promptSettingsManager,
});
const projectConfigTrusted = forceProjectConfigTrusted || projectTrustResolution.trusted;
if (await handlePackageCommand(args, { projectConfigTrusted, projectConfigExists })) {
return;
}
if (await handleConfigCommand(args, { projectConfigTrusted, projectConfigExists })) {
return;
}
}
const parsed = parseArgs(args);
@@ -538,13 +638,35 @@ export async function main(args: string[], options?: MainOptions) {
validateForkFlags(parsed);
validateSessionIdFlags(parsed);
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
time("runMigrations");
const cwd = process.cwd();
const agentDir = getAgentDir();
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const forceProjectConfigTrusted = parsed.force === true;
const sessionTrustOverrides = new Map<string, boolean>();
const getSessionTrustOverride = (targetCwd: string): boolean | undefined => {
return forceProjectConfigTrusted ? true : sessionTrustOverrides.get(getSessionTrustOverrideKey(targetCwd));
};
const startupTrustResolution = await resolveProjectConfigTrusted({
cwd,
agentDir,
sessionTrustOverride: getSessionTrustOverride(cwd),
appMode: trustPromptMode,
settingsManagerForPrompt: promptSettingsManager,
});
if (startupTrustResolution.sessionOverride !== undefined) {
sessionTrustOverrides.set(getSessionTrustOverrideKey(cwd), startupTrustResolution.sessionOverride);
}
const startupProjectConfigTrusted = startupTrustResolution.trusted;
// Legacy extension migrations are intentional filesystem housekeeping and run
// regardless of trust. Trust gates loading/executing project config, not moving
// old Pi config directories to the current layout.
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
time("runMigrations");
const startupSettingsManager = SettingsManager.create(cwd, agentDir, {
projectConfigTrusted: startupProjectConfigTrusted,
});
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
// Decide the final runtime cwd before creating cwd-bound runtime services.
@@ -581,6 +703,25 @@ export async function main(args: string[], options?: MainOptions) {
}
time("createSessionManager");
const initialRuntimeCwd = sessionManager.getCwd();
let runtimeProjectConfigTrusted = startupProjectConfigTrusted;
if (initialRuntimeCwd !== cwd) {
const runtimeTrustResolution = await resolveProjectConfigTrusted({
cwd: initialRuntimeCwd,
agentDir,
sessionTrustOverride: getSessionTrustOverride(initialRuntimeCwd),
appMode: trustPromptMode,
settingsManagerForPrompt: promptSettingsManager,
});
if (runtimeTrustResolution.sessionOverride !== undefined) {
sessionTrustOverrides.set(
getSessionTrustOverrideKey(initialRuntimeCwd),
runtimeTrustResolution.sessionOverride,
);
}
runtimeProjectConfigTrusted = runtimeTrustResolution.trusted;
}
const trustStore = new ProjectTrustStore(agentDir);
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
@@ -592,10 +733,15 @@ export async function main(args: string[], options?: MainOptions) {
sessionManager,
sessionStartEvent,
}) => {
const projectConfigTrusted =
getSessionTrustOverride(cwd) ??
(cwd === initialRuntimeCwd ? runtimeProjectConfigTrusted : trustStore.get(cwd) === true);
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths,
@@ -750,6 +896,19 @@ export async function main(args: string[], options?: MainOptions) {
initialImages,
initialMessages: parsed.messages,
verbose: parsed.verbose,
forceProjectConfigTrust: forceProjectConfigTrusted,
setProjectConfigTrustOverride: (overrideCwd, trusted) => {
const key = getSessionTrustOverrideKey(overrideCwd);
if (trusted === undefined) {
sessionTrustOverrides.delete(key);
} else {
sessionTrustOverrides.set(key, trusted);
}
if (key === getSessionTrustOverrideKey(initialRuntimeCwd)) {
runtimeProjectConfigTrusted =
getSessionTrustOverride(initialRuntimeCwd) ?? trustStore.get(initialRuntimeCwd) === true;
}
},
});
if (startupBenchmark) {
await interactiveMode.init();
+2
View File
@@ -384,6 +384,8 @@ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[]
/**
* Run extension system migrations (commandsprompts) and collect warnings about deprecated directories.
* This intentionally runs even for untrusted projects: it performs legacy Pi config
* housekeeping only and does not load or execute project extensions.
*/
function migrateExtensionSystem(cwd: string): string[] {
const agentDir = getAgentDir();
@@ -15,7 +15,7 @@ import {
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import { CONFIG_DIR_NAME } from "../../../config.ts";
import { CONFIG_DIR_NAME, PROJECT_USER_CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { theme } from "../theme/theme.ts";
@@ -50,7 +50,7 @@ interface ResourceSubgroup {
interface ResourceGroup {
key: string;
label: string;
scope: "user" | "project" | "temporary";
scope: PathMetadata["scope"];
origin: "package" | "top-level";
source: string;
subgroups: ResourceSubgroup[];
@@ -75,18 +75,29 @@ function formatBaseDir(baseDir: string): string {
function getGroupLabel(metadata: PathMetadata): string {
if (metadata.origin === "package") {
return `${metadata.source} (${metadata.scope})`;
return `${metadata.source} (${metadata.scope === "projectUser" ? "project user" : metadata.scope})`;
}
// Top-level resources
if (metadata.source === "auto") {
if (metadata.baseDir) {
return metadata.scope === "user"
? `User (${formatBaseDir(metadata.baseDir)})`
if (metadata.scope === "user") {
return `User (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "projectUser"
? `Project user (${formatBaseDir(metadata.baseDir)})`
: `Project (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
return metadata.scope === "user"
? "User (~/.pi/agent/)"
: metadata.scope === "projectUser"
? "Project user (.pi.user/)"
: "Project (.pi/)";
}
return metadata.scope === "user" ? "User settings" : "Project settings";
return metadata.scope === "user"
? "User settings"
: metadata.scope === "projectUser"
? "Project user settings"
: "Project settings";
}
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
@@ -148,14 +159,15 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
addToGroup(resolved.prompts, "prompts");
addToGroup(resolved.themes, "themes");
// Sort groups: packages first, then top-level; user before project
// Sort groups: packages first, then top-level; user before project.
const scopeOrder: Record<PathMetadata["scope"], number> = { user: 0, projectUser: 1, project: 2, temporary: 3 };
const groups = Array.from(groupMap.values());
groups.sort((a, b) => {
if (a.origin !== b.origin) {
return a.origin === "package" ? -1 : 1;
}
if (a.scope !== b.scope) {
return a.scope === "user" ? -1 : 1;
return scopeOrder[a.scope] - scopeOrder[b.scope];
}
return a.source.localeCompare(b.source);
});
@@ -454,10 +466,27 @@ class ResourceList implements Component, Focusable {
}
}
private getSettingsScope(item: ResourceItem): "user" | "project" | "projectUser" {
return item.metadata.scope === "projectUser"
? "projectUser"
: item.metadata.scope === "project"
? "project"
: "user";
}
private getSettingsForScope(scope: "user" | "project" | "projectUser") {
if (scope === "projectUser") {
return this.settingsManager.getProjectUserSettings();
}
if (scope === "project") {
return this.settingsManager.getProjectSettings();
}
return this.settingsManager.getGlobalSettings();
}
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const scope = this.getSettingsScope(item);
const settings = this.getSettingsForScope(scope);
const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes";
const current = (settings[arrayKey] ?? []) as string[];
@@ -479,7 +508,17 @@ class ResourceList implements Component, Focusable {
updated.push(disablePattern);
}
if (scope === "project") {
if (scope === "projectUser") {
if (arrayKey === "extensions") {
this.settingsManager.setProjectUserExtensionPaths(updated);
} else if (arrayKey === "skills") {
this.settingsManager.setProjectUserSkillPaths(updated);
} else if (arrayKey === "prompts") {
this.settingsManager.setProjectUserPromptTemplatePaths(updated);
} else if (arrayKey === "themes") {
this.settingsManager.setProjectUserThemePaths(updated);
}
} else if (scope === "project") {
if (arrayKey === "extensions") {
this.settingsManager.setProjectExtensionPaths(updated);
} else if (arrayKey === "skills") {
@@ -503,9 +542,8 @@ class ResourceList implements Component, Focusable {
}
private togglePackageResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const scope = this.getSettingsScope(item);
const settings = this.getSettingsForScope(scope);
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
@@ -554,19 +592,24 @@ class ResourceList implements Component, Focusable {
packages[pkgIndex] = (pkg as { source: string }).source;
}
if (scope === "project") {
if (scope === "projectUser") {
this.settingsManager.setProjectUserPackages(packages);
} else if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
} else {
this.settingsManager.setPackages(packages);
}
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
private getTopLevelBaseDir(scope: "user" | "project" | "projectUser"): string {
if (scope === "projectUser") {
return join(this.cwd, PROJECT_USER_CONFIG_DIR_NAME);
}
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
private getResourcePattern(item: ResourceItem): string {
const scope = item.metadata.scope as "user" | "project";
const scope = this.getSettingsScope(item);
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
@@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectConfig, type ProjectTrustDecision, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@@ -255,6 +256,10 @@ export interface InteractiveModeOptions {
initialMessages?: string[];
/** Force verbose startup (overrides quietStartup setting) */
verbose?: boolean;
/** Force project config trust for this process */
forceProjectConfigTrust?: boolean;
/** Update project config trust override for this session and cwd */
setProjectConfigTrustOverride?: (cwd: string, trusted: boolean | undefined) => void;
}
export class InteractiveMode {
@@ -681,6 +686,12 @@ export class InteractiveMode {
this.headerContainer.addChild(this.builtInHeader);
}
if (!this.settingsManager.isProjectConfigTrusted() && hasProjectConfig(this.sessionManager.getCwd())) {
this.chatContainer.addChild(
new Text(theme.fg("warning", "This project is not trusted. Change with /trust"), 1, 0),
);
}
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
@@ -2590,6 +2601,11 @@ export class InteractiveMode {
await this.handleReloadCommand();
return;
}
if (text === "/trust" || text.startsWith("/trust ")) {
this.editor.setText("");
await this.handleTrustCommand(text);
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
@@ -4923,6 +4939,86 @@ export class InteractiveMode {
// Command handlers
// =========================================================================
private formatTrustDecision(decision: ProjectTrustDecision): string {
if (decision === true) {
return "trusted";
}
if (decision === false) {
return "not trusted";
}
return "ask";
}
private async selectTrustDecision(): Promise<{ decision: ProjectTrustDecision; remember: boolean } | undefined> {
const trustStore = new ProjectTrustStore(getAgentDir());
const cwd = this.sessionManager.getCwd();
const current = this.formatTrustDecision(trustStore.get(cwd));
const choice = await this.showExtensionSelector(
`Trust project configuration?\nCurrent setting: ${current}\nLoad .pi and .pi.user from ${cwd}?\nWarning: Project extensions can execute code.`,
["Yes (remember)", "Yes (this session)", "No (remember)", "No (this session)"],
);
if (choice === "Yes (remember)") {
return { decision: true, remember: true };
}
if (choice === "Yes (this session)") {
return { decision: true, remember: false };
}
if (choice === "No (remember)") {
return { decision: false, remember: true };
}
if (choice === "No (this session)") {
return { decision: false, remember: false };
}
return undefined;
}
private async handleTrustCommand(text: string): Promise<void> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before changing trust.");
return;
}
if (this.session.isCompacting) {
this.showWarning("Wait for compaction to finish before changing trust.");
return;
}
const rawArg = text === "/trust" ? "" : text.slice("/trust".length).trim().toLowerCase();
let selection: { decision: ProjectTrustDecision; remember: boolean } | undefined;
if (!rawArg) {
selection = await this.selectTrustDecision();
} else if (rawArg === "yes") {
selection = { decision: true, remember: true };
} else if (rawArg === "no") {
selection = { decision: false, remember: true };
} else if (rawArg === "reset") {
selection = { decision: null, remember: true };
} else {
this.showError("Usage: /trust [yes|no|reset]");
return;
}
if (selection === undefined) {
return;
}
const trustStore = new ProjectTrustStore(getAgentDir());
const cwd = this.sessionManager.getCwd();
if (selection.remember) {
trustStore.set(cwd, selection.decision);
this.options.setProjectConfigTrustOverride?.(cwd, undefined);
} else {
this.options.setProjectConfigTrustOverride?.(
cwd,
selection.decision === null ? undefined : selection.decision,
);
}
const projectConfigTrusted = this.options.forceProjectConfigTrust === true || selection.decision === true;
this.settingsManager.setProjectConfigTrusted(projectConfigTrusted);
await this.handleReloadCommand();
const suffix = selection.remember ? "" : " (this session)";
this.showStatus(`Project trust: ${this.formatTrustDecision(selection.decision)}${suffix}`);
}
private async handleReloadCommand(): Promise<void> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before reloading.");
@@ -14,6 +14,7 @@ import {
} from "./config.ts";
import { DefaultPackageManager } from "./core/package-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectConfig } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import {
@@ -47,6 +48,7 @@ interface PackageCommandOptions {
source?: string;
updateTarget?: UpdateTarget;
local: boolean;
localUser: boolean;
force: boolean;
help: boolean;
invalidOption?: string;
@@ -55,6 +57,11 @@ interface PackageCommandOptions {
conflictingOptions?: string;
}
interface ProjectConfigCommandContext {
projectConfigTrusted?: boolean;
projectConfigExists?: boolean;
}
function reportSettingsErrors(settingsManager: SettingsManager, context: string): void {
const errors = settingsManager.drainErrors();
for (const { scope, error } of errors) {
@@ -68,13 +75,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string)
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l]`;
return `${APP_NAME} install <source> [-l] [-u]`;
case "remove":
return `${APP_NAME} remove <source> [-l]`;
return `${APP_NAME} remove <source> [-l] [-u]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
case "list":
return `${APP_NAME} list`;
return `${APP_NAME} list [--force]`;
}
}
@@ -88,6 +95,8 @@ Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-u, --user With --local, write to .pi.user/settings.json instead
-f, --force Trust project config for this command
Examples:
${APP_NAME} install npm:@foo/bar
@@ -104,10 +113,12 @@ Examples:
${getPackageCommandUsage("remove")}
Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Alias: ${APP_NAME} uninstall <source> [-l] [-u]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-u, --user With --local, remove from .pi.user/settings.json instead
-f, --force Trust project config for this command
Examples:
${APP_NAME} remove npm:@foo/bar
@@ -139,6 +150,9 @@ Short forms:
${getPackageCommandUsage("list")}
List installed packages from user and project settings.
Options:
-f, --force Trust project config for this command
`);
return;
}
@@ -157,6 +171,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
let local = false;
let localUser = false;
let force = false;
let help = false;
let invalidOption: string | undefined;
@@ -184,6 +199,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "-u" || arg === "--user") {
if (command === "install" || command === "remove") {
localUser = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg === "--self") {
if (command === "update") {
selfFlag = true;
@@ -202,12 +226,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "--force") {
if (command === "update") {
force = true;
} else {
invalidOption = invalidOption ?? arg;
}
if (arg === "--force" || arg === "-f") {
force = true;
continue;
}
@@ -274,11 +294,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
}
if (localUser && !local) {
conflictingOptions = conflictingOptions ?? "--user can only be used with --local";
}
return {
command,
source,
updateTarget,
local,
localUser,
force,
help,
invalidOption,
@@ -288,6 +313,14 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
};
}
export function packageCommandForcesProjectConfigTrust(args: string[]): boolean {
const options = parsePackageCommand(args);
return (
options?.force === true &&
(options.command === "install" || options.command === "remove" || options.command === "list")
);
}
function updateTargetIncludesSelf(target: UpdateTarget): boolean {
return target.type === "all" || target.type === "self";
}
@@ -389,14 +422,16 @@ function prepareWindowsNpmSelfUpdate(): void {
quarantineWindowsNativeDependencies(packageDir);
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
export async function handleConfigCommand(args: string[], context: ProjectConfigCommandContext = {}): Promise<boolean> {
if (args[0] !== "config") {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const settingsManager = SettingsManager.create(cwd, agentDir, {
projectConfigTrusted: context.projectConfigTrusted ?? true,
});
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
@@ -411,7 +446,10 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
process.exit(0);
}
export async function handlePackageCommand(args: string[]): Promise<boolean> {
export async function handlePackageCommand(
args: string[],
context: ProjectConfigCommandContext = {},
): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
@@ -459,8 +497,20 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
}
const cwd = process.cwd();
const projectConfigTrusted = context.projectConfigTrusted ?? true;
const projectConfigExists = context.projectConfigExists ?? hasProjectConfig(cwd);
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
if (!projectConfigTrusted && projectConfigExists && writesProjectPackageConfig) {
console.error(chalk.red("Project config is not trusted. Use --force to modify local package config."));
process.exitCode = 1;
return true;
}
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const effectiveProjectConfigTrusted = projectConfigTrusted || (writesProjectPackageConfig && !projectConfigExists);
const settingsManager = SettingsManager.create(cwd, agentDir, {
projectConfigTrusted: effectiveProjectConfigTrusted,
});
reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;
@@ -475,12 +525,15 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
try {
switch (options.command) {
case "install":
await packageManager.installAndPersist(source!, { local: options.local });
await packageManager.installAndPersist(source!, { local: options.local, localUser: options.localUser });
console.log(chalk.green(`Installed ${source}`));
return true;
case "remove": {
const removed = await packageManager.removeAndPersist(source!, { local: options.local });
const removed = await packageManager.removeAndPersist(source!, {
local: options.local,
localUser: options.localUser,
});
if (!removed) {
console.error(chalk.red(`No matching package found for ${source}`));
process.exitCode = 1;
@@ -494,6 +547,7 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const configuredPackages = packageManager.listConfiguredPackages();
const userPackages = configuredPackages.filter((pkg) => pkg.scope === "user");
const projectPackages = configuredPackages.filter((pkg) => pkg.scope === "project");
const projectUserPackages = configuredPackages.filter((pkg) => pkg.scope === "projectUser");
if (configuredPackages.length === 0) {
console.log(chalk.dim("No packages installed."));
@@ -523,6 +577,14 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
}
}
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;
}
+12
View File
@@ -300,6 +300,18 @@ describe("parseArgs", () => {
});
});
describe("--force flag", () => {
test("parses --force flag", () => {
const result = parseArgs(["--force"]);
expect(result.force).toBe(true);
});
test("parses -f shorthand", () => {
const result = parseArgs(["-f"]);
expect(result.force).toBe(true);
});
});
describe("--offline flag", () => {
test("parses --offline flag", () => {
const result = parseArgs(["--offline"]);
@@ -1,4 +1,4 @@
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -85,6 +85,66 @@ describe("package commands", () => {
expect(removedSettings.packages ?? []).toHaveLength(0);
});
it("skips untrusted project package settings", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
} finally {
logSpy.mockRestore();
}
});
it("forces project trust for list with --force", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--force"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("blocks local package changes when project config is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain("Project config is not trusted. Use --force to modify local package config.");
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
}
});
it("allows local package install to initialize fresh project config", async () => {
await main(["install", "-l", packageDir]);
const settingsPath = join(projectDir, ".pi", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
expect(settings.packages?.length).toBe(1);
const stored = settings.packages?.[0] ?? "";
expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir));
expect(process.exitCode).toBeUndefined();
});
it("shows install subcommand help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -111,7 +171,7 @@ describe("package commands", () => {
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain('Unknown option --unknown for "install".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [-u]".');
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
@@ -134,6 +194,60 @@ describe("package commands", () => {
}
});
it("does not treat update --force as a project trust override", async () => {
const globalPrefix = join(tempDir, "global-prefix");
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
const fakeNpmPath = join(tempDir, "fake-npm-record.cjs");
const recordPath = join(tempDir, "update-force-records.json");
mkdirSync(selfPackageDir, { recursive: true });
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(
fakeNpmPath,
`const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1];
if(args.includes("root")) {
console.log(path.join(prefix,"lib","node_modules"));
process.exit(0);
}
const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[];
records.push(args);
fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records));
`,
);
writeFileSync(
join(agentDir, "settings.json"),
JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2),
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ packages: ["npm:@project/pkg"] }, null, 2),
);
process.env.PI_PACKAGE_DIR = selfPackageDir;
Object.defineProperty(process, "execPath", {
value: join(selfPackageDir, "dist", "cli.js"),
configurable: true,
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--force"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(existsSync(recordPath)).toBe(true);
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
expect(recordedCalls.some((args) => args.some((arg) => arg.includes("@project/pkg")))).toBe(false);
expect(recordedCalls).toEqual([expect.arrayContaining(["install", "-g", PACKAGE_NAME])]);
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("uses global npmCommand and current package name for forced self updates without checking the api", async () => {
const globalPrefix = join(tempDir, "global-prefix");
const projectPrefix = join(tempDir, "project-prefix");
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { PassThrough } from "node:stream";
@@ -156,6 +156,38 @@ Content`,
expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true);
});
it("should resolve project user paths relative to .pi.user", async () => {
const extDir = join(tempDir, ".pi.user", "extensions");
mkdirSync(extDir, { recursive: true });
const extPath = join(extDir, "project-user-ext.ts");
writeFileSync(extPath, "export default function() {}");
settingsManager.setProjectUserExtensionPaths(["extensions/project-user-ext.ts"]);
const result = await packageManager.resolve();
const ext = result.extensions.find((r) => r.path === extPath);
expect(ext?.enabled).toBe(true);
expect(ext?.metadata.scope).toBe("projectUser");
});
it("should prefer .pi.user resources over .pi resources", async () => {
const projectExtDir = join(tempDir, ".pi", "extensions");
const projectUserExtDir = join(tempDir, ".pi.user", "extensions");
mkdirSync(projectExtDir, { recursive: true });
mkdirSync(projectUserExtDir, { recursive: true });
const projectExtPath = join(projectExtDir, "shared.ts");
const projectUserExtPath = join(projectUserExtDir, "shared.ts");
writeFileSync(projectExtPath, "export default function() {}");
writeFileSync(projectUserExtPath, "export default function() {}");
const result = await packageManager.resolve();
const sharedPaths = result.extensions.filter((r) => r.path.endsWith("shared.ts"));
expect(sharedPaths).toHaveLength(2);
expect(sharedPaths[0].path).toBe(projectUserExtPath);
expect(sharedPaths[1].path).toBe(projectExtPath);
});
it("should auto-discover user prompts with overrides", async () => {
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
@@ -708,6 +740,21 @@ Content`,
);
});
it("should create ignored .pi.user folder for project user npm installs", async () => {
expect(existsSync(join(tempDir, ".pi.user"))).toBe(false);
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined);
await packageManager.install("npm:@scope/pkg", { local: true, localUser: true });
expect(readFileSync(join(tempDir, ".pi.user", ".gitignore"), "utf-8")).toBe("*\n.*\n");
expect(runCommandSpy).toHaveBeenCalledWith(
"npm",
["install", "@scope/pkg", "--prefix", join(tempDir, ".pi.user", "npm"), "--legacy-peer-deps"],
undefined,
);
});
it("should use bun --cwd for npm package installs", async () => {
settingsManager = SettingsManager.inMemory({
npmCommand: ["mise", "exec", "bun@1", "--", "bun"],
@@ -1167,6 +1214,23 @@ Content`,
expect(settings.packages?.[0]).toBe(expected);
});
it("should store project user local packages relative to .pi.user settings base", () => {
const projectPkgDir = join(tempDir, "project-user-local-pkg");
mkdirSync(join(projectPkgDir, "extensions"), { recursive: true });
writeFileSync(join(projectPkgDir, "extensions", "index.ts"), "export default function() {}");
const added = packageManager.addSourceToSettings("./project-user-local-pkg", {
local: true,
localUser: true,
});
expect(added).toBe(true);
const settings = settingsManager.getProjectUserSettings();
const rel = relative(join(tempDir, ".pi.user"), projectPkgDir);
const expected = rel.startsWith(".") ? rel : `./${rel}`;
expect(settings.packages?.[0]).toBe(expected);
});
it("should remove local package entries using equivalent path forms", () => {
const pkgDir = join(tempDir, "remove-local-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
@@ -329,6 +329,33 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should prefer SYSTEM.md from cwd/.pi.user", async () => {
const piDir = join(cwd, ".pi");
const piUserDir = join(cwd, ".pi.user");
mkdirSync(piDir, { recursive: true });
mkdirSync(piUserDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(piUserDir, "SYSTEM.md"), "Project user system prompt.");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Project user system prompt.");
});
it("should skip .pi SYSTEM.md when project config is not trusted", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt.");
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
});
it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });
@@ -258,6 +258,46 @@ describe("SettingsManager", () => {
});
});
describe("project user settings", () => {
it("should let .pi.user override .pi and global settings", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
mkdirSync(join(projectDir, ".pi.user"), { recursive: true });
writeFileSync(join(projectDir, ".pi.user", "settings.json"), JSON.stringify({ theme: "project-user" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTheme()).toBe("project-user");
expect(manager.getProjectSettings().theme).toBe("project");
expect(manager.getProjectUserSettings().theme).toBe("project-user");
});
it("should skip project settings when project config is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
mkdirSync(join(projectDir, ".pi.user"), { recursive: true });
writeFileSync(join(projectDir, ".pi.user", "settings.json"), JSON.stringify({ theme: "project-user" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectConfigTrusted: false });
expect(manager.getTheme()).toBe("global");
expect(manager.getProjectSettings()).toEqual({});
expect(manager.getProjectUserSettings()).toEqual({});
expect(manager.getProjectSettingsLayers()).toEqual([]);
});
it("should create ignored .pi.user folder when writing project user settings", async () => {
rmSync(join(projectDir, ".pi.user"), { recursive: true, force: true });
const manager = SettingsManager.create(projectDir, agentDir);
manager.setProjectUserPackages(["npm:test-pkg"]);
await manager.flush();
expect(existsSync(join(projectDir, ".pi.user", "settings.json"))).toBe(true);
expect(readFileSync(join(projectDir, ".pi.user", ".gitignore"), "utf-8")).toBe("*\n.*\n");
});
});
describe("httpIdleTimeoutMs", () => {
it("should default to 5 minutes", () => {
const manager = SettingsManager.create(projectDir, agentDir);
@@ -0,0 +1,57 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasProjectConfig, ProjectTrustStore } from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("stores decisions per cwd", () => {
const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull();
store.set(cwd, true);
expect(store.get(cwd)).toBe(true);
store.set(cwd, false);
expect(store.get(cwd)).toBe(false);
store.set(cwd, null);
expect(store.get(cwd)).toBeNull();
});
it("fails loudly without overwriting malformed trust stores", () => {
const trustPath = join(agentDir, "trust.json");
writeFileSync(trustPath, "{not json", "utf-8");
const store = new ProjectTrustStore(agentDir);
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/);
expect(readFileSync(trustPath, "utf-8")).toBe("{not json");
});
it("detects .pi and .pi.user project config directories", () => {
expect(hasProjectConfig(cwd)).toBe(false);
mkdirSync(join(cwd, ".pi"), { recursive: true });
expect(hasProjectConfig(cwd)).toBe(true);
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
mkdirSync(join(cwd, ".pi.user"), { recursive: true });
expect(hasProjectConfig(cwd)).toBe(true);
expect(existsSync(join(cwd, ".pi.user"))).toBe(true);
});
});