mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(coding-agent): add project config trust approvals
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -384,6 +384,8 @@ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[]
|
||||
|
||||
/**
|
||||
* Run extension system migrations (commands→prompts) and collect warnings about deprecated directories.
|
||||
* This intentionally runs even for untrusted projects: it performs legacy Pi config
|
||||
* housekeeping only and does not load or execute project extensions.
|
||||
*/
|
||||
function migrateExtensionSystem(cwd: string): string[] {
|
||||
const agentDir = getAgentDir();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user