feat(coding-agent): extension package management and hot reload

Implements #645:
- ResourceLoader/PackageManager system replacing legacy discovery
- CLI commands: pi install, pi remove, pi update, pi list
- /reload command for hot reload in interactive mode
- session.reload() and session.bindExtensions() APIs
- CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates
- Extension conflict detection with filtering
- Support for npm:, git:, local paths, and raw git URLs
- Auto npm install for git repos with package.json

closes #645
This commit is contained in:
Mario Zechner 2026-01-22 13:52:09 +01:00
commit d99b7057ca
55 changed files with 3337 additions and 1852 deletions

View file

@ -1,64 +0,0 @@
---
name: pi-tmux-test
description: Test pi's interactive mode via tmux. Use when you need to test TUI behavior, extensions, or interactive features programmatically.
---
# Testing Pi Interactively via tmux
Use tmux to test pi's interactive mode. This allows sending input and capturing output programmatically.
## Setup
```bash
# Kill any existing test session and create a new one
tmux kill-session -t pi-test 2>/dev/null
tmux new-session -d -s pi-test -c /Users/badlogic/workspaces/pi-mono -x 100 -y 30
# Start pi using the test script (runs via tsx, picks up source changes)
# Always use --no-session to avoid creating session files during testing
tmux send-keys -t pi-test "./pi-test.sh --no-session" Enter
# Wait for startup
sleep 4
tmux capture-pane -t pi-test -p
```
## Interaction
```bash
# Send input
tmux send-keys -t pi-test "your message here" Enter
# Wait and capture output
sleep 5
tmux capture-pane -t pi-test -p
# Send special keys
tmux send-keys -t pi-test Escape
tmux send-keys -t pi-test C-c # Ctrl+C
tmux send-keys -t pi-test C-d # Ctrl+D
```
## Cleanup
```bash
tmux kill-session -t pi-test
```
## Testing Extensions
Write extensions to /tmp and load with `-e`:
```bash
cat > /tmp/test-extension.ts << 'EOF'
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// extension code
}
EOF
# Run pi with the extension
tmux send-keys -t pi-test "./pi-test.sh --no-session -e /tmp/test-extension.ts" Enter
```
Clean up after testing: `rm /tmp/test-extension.ts`

View file

@ -2,6 +2,22 @@
## [Unreleased]
### Breaking Changes
- Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
- Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645))
### Added
- `markdown.codeBlockIndent` setting to customize code block indentation in rendered output
- Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645))
- `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
- CLI flags for `--skill`, `--prompt-template`, `--theme`, `--no-prompt-templates`, and `--no-themes` ([#645](https://github.com/badlogic/pi-mono/issues/645))
### Changed
- Skill, prompt template, and theme discovery now use settings and CLI path arrays instead of legacy filters ([#645](https://github.com/badlogic/pi-mono/issues/645))
## [0.49.3] - 2026-01-22
### Added

View file

@ -315,6 +315,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/new` | Start a new session |
| `/copy` | Copy last agent message to clipboard |
| `/compact [instructions]` | Manually compact conversation context |
| `/reload` | Reload extensions, skills, prompts, and themes |
### Editor Features
@ -791,9 +792,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
"reserveTokens": 16384,
"keepRecentTokens": 20000
},
"skills": {
"enabled": true
},
"skills": ["/path/to/skills"],
"prompts": ["/path/to/prompts"],
"themes": ["/path/to/themes"],
"enableSkillCommands": true,
"retry": {
"enabled": true,
"maxRetries": 3,
@ -827,7 +829,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
| `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` |
| `skills` | Additional skill file or directory paths | `[]` |
| `prompts` | Additional prompt template paths | `[]` |
| `themes` | Additional theme file or directory paths | `[]` |
| `enableSkillCommands` | Register skills as `/skill:name` commands | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
@ -835,10 +840,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
| `images.blockImages` | Prevent images from being sent to LLM providers | `false` |
| `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` |
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` |
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` |
| `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` |
| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` |
| `extensions` | Additional extension file paths | `[]` |
| `extensions` | Extension sources or file paths (npm:, git:, local) | `[]` |
---
@ -852,6 +857,8 @@ Select theme via `/settings` or set in `~/.pi/agent/settings.json`.
**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.
Add additional theme paths via `settings.json` `themes` array or `--theme <path>`. Disable automatic theme discovery with `--no-themes`.
```bash
mkdir -p ~/.pi/agent/themes
cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
@ -870,6 +877,8 @@ Define reusable prompts as Markdown files:
**Locations:**
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Additional paths from settings.json `prompts` array
- CLI `--prompt-template` paths
**Format:**
@ -900,6 +909,8 @@ Usage: `/component Button "onClick handler" "disabled support"`
- `${@:N}` = arguments from the Nth position onwards (1-indexed)
- `${@:N:L}` = `L` arguments starting from the Nth position
Disable prompt template discovery with `--no-prompt-templates`.
**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md``/component (project:frontend)`
@ -918,10 +929,12 @@ A skill provides specialized workflows, setup instructions, helper scripts, and
- YouTube transcript extraction
**Skill locations:**
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
- Pi project: `.pi/skills/**/SKILL.md` (recursive)
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)
- Global: `~/.pi/agent/skills/`
- Project: `.pi/skills/`
- Additional paths from settings.json `skills` array
- CLI `--skill` paths (additive even with `--no-skills`)
Use `enableSkillCommands` in settings to toggle `/skill:name` commands.
**Format:**
@ -948,7 +961,7 @@ cd /path/to/brave-search && npm install
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
- `description`: Required. Max 1024 chars. Determines when the skill is loaded.
**Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings.
**Disable skills:** `pi --no-skills` (automatic discovery off, explicit `--skill` paths still load).
> See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills.
@ -967,7 +980,18 @@ Extensions are TypeScript modules that extend pi's behavior.
**Locations:**
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts`
- CLI: `--extension <path>` or `-e <path>`
- Settings: `extensions` array supports file paths and `npm:` or `git:` sources
- CLI: `--extension <path>` or `-e <path>` (temporary for this run)
Install and remove extension sources with the CLI:
```bash
pi install npm:@foo/bar@1.0.0
pi install git:github.com/user/repo@v1
pi remove npm:@foo/bar
```
Use `-l` to install into project settings (`.pi/settings.json`).
**Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/).
@ -1214,6 +1238,15 @@ await ctx.ui.custom((tui, theme, done) => ({
pi [options] [@files...] [messages...]
```
### Commands
| Command | Description |
|---------|-------------|
| `install <source> [-l]` | Install extension source and add to settings (`-l` for project) |
| `remove <source> [-l]` | Remove extension source from settings |
| `update [source]` | Update installed extensions (skips pinned sources) |
| `list` | List installed extensions from settings |
### Options
| Option | Description |
@ -1236,8 +1269,12 @@ pi [options] [@files...] [messages...]
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
| `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) |
| `--skill <path>` | Load a skill file or directory (can be used multiple times) |
| `--prompt-template <path>` | Load a prompt template file or directory (can be used multiple times) |
| `--theme <path>` | Load a theme file or directory (can be used multiple times) |
| `--no-skills` | Disable skills discovery and loading |
| `--skills <patterns>` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) |
| `--no-prompt-templates` | Disable prompt template discovery and loading |
| `--no-themes` | Disable theme discovery and loading |
| `--export <file> [output]` | Export session to HTML |
| `--help`, `-h` | Show help |
| `--version`, `-v` | Show version |
@ -1339,10 +1376,10 @@ For adding new tools, see [Extensions](#extensions) in the Customization section
For embedding pi in Node.js/TypeScript applications, use the SDK:
```typescript
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
@ -1361,15 +1398,13 @@ await session.prompt("What files are in the current directory?");
The SDK provides full control over:
- Model selection and thinking level
- System prompt (replace or modify)
- Tools (built-in subsets, custom tools)
- Extensions (discovered or via paths)
- Skills, context files, prompt templates
- Resources via `ResourceLoader` (extensions, skills, prompts, themes, context files, system prompt)
- Session persistence (`SessionManager`)
- Settings (`SettingsManager`)
- API key resolution and OAuth
**Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used.
**Philosophy:** "Omit to discover, provide to override." Resource discovery is handled by `ResourceLoader`, while model and tool options still follow the omit or override pattern.
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.

View file

@ -115,10 +115,22 @@ Additional paths via `settings.json`:
```json
{
"extensions": ["/path/to/extension.ts", "/path/to/extension/dir"]
"extensions": [
"npm:@foo/bar@1.0.0",
"git:github.com/user/repo@v1",
"/path/to/extension.ts",
"/path/to/extension/dir"
]
}
```
Use `pi install` and `pi remove` to manage extension sources in settings:
```bash
pi install npm:@foo/bar@1.0.0
pi remove npm:@foo/bar
```
**Discovery rules:**
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
@ -146,13 +158,17 @@ Additional paths via `settings.json`:
"zod": "^3.0.0"
},
"pi": {
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"]
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"],
"skills": ["./skills/"],
"prompts": ["./prompts/"],
"themes": ["./themes/dark.json"]
}
}
```
The `package.json` approach enables:
- Multiple extensions from one package
- Skills, prompts, and themes declared alongside extensions
- Third-party npm dependencies (resolved via jiti)
- Nested source structure (no depth limit within the package)
- Deployment to and installation from npm

View file

@ -16,11 +16,11 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
## Quick Start
```typescript
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
// Set up credential storage and model registry
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
@ -51,20 +51,17 @@ The SDK is included in the main package. No separate installation needed.
The main factory function. Creates an `AgentSession` with configurable options.
**Philosophy:** "Omit to discover, provide to override."
- Omit an option → pi discovers/loads from standard locations
- Provide an option → your value is used, discovery skipped for that option
`createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery.
```typescript
import { createAgentSession } from "@mariozechner/pi-coding-agent";
// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent)
// Minimal: defaults with DefaultResourceLoader
const { session } = await createAgentSession();
// Custom: override specific options
const { session } = await createAgentSession({
model: myModel,
systemPrompt: "You are helpful.",
tools: [readTool, bashTool],
sessionManager: SessionManager.inMemory(),
});
@ -251,7 +248,7 @@ session.subscribe((event) => {
```typescript
const { session } = await createAgentSession({
// Working directory for project-local discovery
// Working directory for DefaultResourceLoader discovery
cwd: process.cwd(), // default
// Global config directory
@ -259,14 +256,14 @@ const { session } = await createAgentSession({
});
```
`cwd` is used for:
`cwd` is used by `DefaultResourceLoader` for:
- Project extensions (`.pi/extensions/`)
- Project skills (`.pi/skills/`)
- Project prompts (`.pi/prompts/`)
- Context files (`AGENTS.md` walking up from cwd)
- Session directory naming
`agentDir` is used for:
`agentDir` is used by `DefaultResourceLoader` for:
- Global extensions (`extensions/`)
- Global skills (`skills/`)
- Global prompts (`prompts/`)
@ -276,14 +273,16 @@ const { session } = await createAgentSession({
- Credentials (`auth.json`)
- Sessions (`sessions/`)
When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control resource discovery. They still influence session naming and tool path resolution.
### Model
```typescript
import { getModel } from "@mariozechner/pi-ai";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
@ -327,11 +326,11 @@ API key resolution priority (handled by AuthStorage):
4. Fallback resolver (for custom provider keys from `models.json`)
```typescript
import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
@ -360,16 +359,17 @@ const simpleRegistry = new ModelRegistry(authStorage);
### System Prompt
Use a `ResourceLoader` to override the system prompt:
```typescript
const { session } = await createAgentSession({
// Replace entirely
systemPrompt: "You are a helpful assistant.",
// Or modify default (receives default, returns modified)
systemPrompt: (defaultPrompt) => {
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
},
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
const loader = new DefaultResourceLoader({
systemPromptOverride: () => "You are a helpful assistant.",
});
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader });
```
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
@ -462,61 +462,45 @@ const { session } = await createAgentSession({
});
```
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions discovered from `~/.pi/agent/extensions/` and `.pi/extensions/` can also register tools via `pi.registerTool()`.
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Extensions
By default, extensions are discovered from multiple locations:
- `~/.pi/agent/extensions/` (global)
- `.pi/extensions/` (project-local)
- Paths listed in `settings.json` `"extensions"` array
Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
```typescript
import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
// Inline extension factory
const myExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event, ctx) => {
console.log(`Tool: ${event.toolName}`);
});
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello!", "info");
},
});
};
// Pass inline extensions (skips file discovery)
const { session } = await createAgentSession({
extensions: [myExtension],
});
// Add paths to load (merged with discovery)
const { session } = await createAgentSession({
const loader = new DefaultResourceLoader({
additionalExtensionPaths: ["/path/to/my-extension.ts"],
extensionFactories: [
(pi) => {
pi.on("agent_start", () => {
console.log("[Inline Extension] Agent starting");
});
},
],
});
await loader.reload();
// Disable extension discovery entirely
const { session } = await createAgentSession({
extensions: [],
});
const { session } = await createAgentSession({ resourceLoader: loader });
```
Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `createAgentSession()` if you need to emit/listen from outside:
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside:
```typescript
import { createAgentSession, createEventBus } from "@mariozechner/pi-coding-agent";
import { createEventBus, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
const eventBus = createEventBus();
const { session } = await createAgentSession({ eventBus });
const loader = new DefaultResourceLoader({
eventBus,
});
await loader.reload();
// Listen for events from extensions
eventBus.on("my-extension:status", (data) => console.log(data));
```
@ -525,14 +509,13 @@ eventBus.on("my-extension:status", (data) => console.log(data));
### Skills
```typescript
import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
type Skill,
} from "@mariozechner/pi-coding-agent";
// Discover and filter
const { skills: allSkills, warnings } = discoverSkills();
const filtered = allSkills.filter(s => s.name.includes("search"));
// Custom skill
const mySkill: Skill = {
const customSkill: Skill = {
name: "my-skill",
description: "Custom instructions",
filePath: "/path/to/SKILL.md",
@ -540,20 +523,15 @@ const mySkill: Skill = {
source: "custom",
};
const { session } = await createAgentSession({
skills: [...filtered, mySkill],
const loader = new DefaultResourceLoader({
skillsOverride: (current) => ({
skills: [...current.skills, customSkill],
diagnostics: current.diagnostics,
}),
});
await loader.reload();
// Disable skills
const { session } = await createAgentSession({
skills: [],
});
// Discovery with settings filter
const { skills } = discoverSkills(process.cwd(), undefined, {
ignoredSkills: ["browser-*"], // glob patterns to exclude
includeSkills: ["search-*"], // glob patterns to include (empty = all)
});
const { session } = await createAgentSession({ resourceLoader: loader });
```
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
@ -561,26 +539,19 @@ const { skills } = discoverSkills(process.cwd(), undefined, {
### Context Files
```typescript
import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files
const discovered = discoverContextFiles();
// Add custom context
const { session } = await createAgentSession({
contextFiles: [
...discovered,
{
path: "/virtual/AGENTS.md",
content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
},
],
const loader = new DefaultResourceLoader({
agentsFilesOverride: (current) => ({
agentsFiles: [
...current.agentsFiles,
{ path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
],
}),
});
await loader.reload();
// Disable context files
const { session } = await createAgentSession({
contextFiles: [],
});
const { session } = await createAgentSession({ resourceLoader: loader });
```
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
@ -588,9 +559,11 @@ const { session } = await createAgentSession({
### Slash Commands
```typescript
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent";
const discovered = discoverPromptTemplates();
import {
createAgentSession,
DefaultResourceLoader,
type PromptTemplate,
} from "@mariozechner/pi-coding-agent";
const customCommand: PromptTemplate = {
name: "deploy",
@ -599,9 +572,15 @@ const customCommand: PromptTemplate = {
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
};
const { session } = await createAgentSession({
promptTemplates: [...discovered, customCommand],
const loader = new DefaultResourceLoader({
promptsOverride: (current) => ({
prompts: [...current.prompts, customCommand],
diagnostics: current.diagnostics,
}),
});
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader });
```
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
@ -719,62 +698,31 @@ Settings load from two locations and merge:
1. Global: `~/.pi/agent/settings.json`
2. Project: `<cwd>/.pi/settings.json`
Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control).
Project overrides global. Nested objects merge keys. Setters modify global settings by default.
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
## Discovery Functions
## ResourceLoader
All discovery functions accept optional `cwd` and `agentDir` parameters.
Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
```typescript
import { getModel } from "@mariozechner/pi-ai";
import {
AuthStorage,
ModelRegistry,
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverExtensions,
discoverContextFiles,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
createEventBus,
DefaultResourceLoader,
getAgentDir,
} from "@mariozechner/pi-coding-agent";
// Auth and Models
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
const allModels = modelRegistry.getAll(); // All models (built-in + custom)
const available = await modelRegistry.getAvailable(); // Only models with API keys
const model = modelRegistry.find("provider", "id"); // Find specific model
const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
// Skills
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
// Extensions (async - loads TypeScript)
// Pass eventBus to share pi.events across extensions
const eventBus = createEventBus();
const { extensions, errors } = await discoverExtensions(eventBus, cwd, agentDir);
// Context files
const contextFiles = discoverContextFiles(cwd, agentDir);
// Prompt templates
const templates = discoverPromptTemplates(cwd, agentDir);
// Settings (global + project merged)
const settings = loadSettings(cwd, agentDir);
// Build system prompt manually
const prompt = buildSystemPrompt({
skills,
contextFiles,
appendPrompt: "Additional instructions",
const loader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
});
await loader.reload();
const extensions = loader.getExtensions();
const skills = loader.getSkills();
const prompts = loader.getPrompts();
const themes = loader.getThemes();
const contextFiles = loader.getAgentsFiles().agentsFiles;
```
## Return Value
@ -808,12 +756,12 @@ import { Type } from "@sinclair/typebox";
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
ModelRegistry,
SessionManager,
SettingsManager,
readTool,
bashTool,
type ExtensionFactory,
type ToolDefinition,
} from "@mariozechner/pi-coding-agent";
@ -828,14 +776,6 @@ if (process.env.MY_KEY) {
// Model registry (no custom models.json)
const modelRegistry = new ModelRegistry(authStorage);
// Inline extension
const auditExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
});
};
// Inline tool
const statusTool: ToolDefinition = {
name: "status",
@ -857,24 +797,27 @@ const settingsManager = SettingsManager.inMemory({
retry: { enabled: true, maxRetries: 2 },
});
const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: "/custom/agent",
settingsManager,
systemPromptOverride: () => "You are a minimal assistant. Be concise.",
});
await loader.reload();
const { session } = await createAgentSession({
cwd: process.cwd(),
agentDir: "/custom/agent",
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
systemPrompt: "You are a minimal assistant. Be concise.",
tools: [readTool, bashTool],
customTools: [statusTool],
extensions: [auditExtension],
skills: [],
contextFiles: [],
promptTemplates: [],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
settingsManager,
});
@ -976,21 +919,13 @@ createAgentSession
// Auth and Models
AuthStorage
ModelRegistry
discoverAuthStorage
discoverModels
// Discovery
discoverSkills
discoverExtensions
discoverContextFiles
discoverPromptTemplates
// Event Bus (for shared extension communication)
// Resource loading
DefaultResourceLoader
type ResourceLoader
createEventBus
// Helpers
loadSettings
buildSystemPrompt
// Session management
SessionManager
@ -1016,8 +951,6 @@ type ExtensionAPI
type ToolDefinition
type Skill
type PromptTemplate
type Settings
type SkillsSettings
type Tool
```

View file

@ -141,64 +141,43 @@ Run the extraction script:
Skills are discovered from these locations (later wins on name collision):
1. `~/.codex/skills/**/SKILL.md` (Codex CLI, recursive)
2. `~/.claude/skills/*/SKILL.md` (Claude Code user, one level)
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project, one level)
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive)
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive)
1. `~/.pi/agent/skills/` (global)
2. `<cwd>/.pi/skills/` (project)
3. Paths listed in `settings.json` under `skills`
4. CLI `--skill` paths (additive even with `--no-skills`)
Discovery rules for each directory:
- Direct `.md` files in the root
- Recursive `SKILL.md` files under subdirectories
## Configuration
Configure skill loading in `~/.pi/agent/settings.json`:
Configure skill paths in `~/.pi/agent/settings.json`:
```json
{
"skills": {
"enabled": true,
"enableCodexUser": true,
"enableClaudeUser": true,
"enableClaudeProject": true,
"enablePiUser": true,
"enablePiProject": true,
"enableSkillCommands": true,
"customDirectories": ["~/my-skills-repo"],
"ignoredSkills": ["deprecated-skill"],
"includeSkills": ["git-*", "docker"]
}
"skills": ["~/my-skills-repo", "/path/to/skill/SKILL.md"],
"enableSkillCommands": true
}
```
Use project-local settings in `<cwd>/.pi/settings.json` to scope skills to a single project.
| Setting | Default | Description |
|---------|---------|-------------|
| `enabled` | `true` | Master toggle for all skills |
| `enableCodexUser` | `true` | Load from `~/.codex/skills/` |
| `enableClaudeUser` | `true` | Load from `~/.claude/skills/` |
| `enableClaudeProject` | `true` | Load from `<cwd>/.claude/skills/` |
| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` |
| `enablePiProject` | `true` | Load from `<cwd>/.pi/skills/` |
| `skills` | `[]` | Additional skill file or directory paths |
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `["deprecated-*", "test-skill"]`) |
| `includeSkills` | `[]` | Glob patterns to include (empty = all; e.g., `["git-*", "docker"]`) |
**Note:** `ignoredSkills` takes precedence over both `includeSkills` in settings and the `--skills` CLI flag. A skill matching any ignore pattern will be excluded regardless of include patterns.
### CLI Additions
### CLI Filtering
Use `--skills` to filter skills for a specific invocation:
Use `--skill` to add skills for a specific invocation:
```bash
# Only load specific skills
pi --skills git,docker
# Glob patterns
pi --skills "git-*,docker-*"
# All skills matching a prefix
pi --skills "aws-*"
pi --skill ~/my-skills/terraform
pi --skill ./skills/custom-skill/SKILL.md
```
This overrides the `includeSkills` setting for the current session.
Use `--no-skills` to disable automatic discovery (CLI `--skill` paths still load).
## How Skills Work
@ -224,9 +203,7 @@ Toggle skill commands via `/settings` or in `settings.json`:
```json
{
"skills": {
"enableSkillCommands": true
}
"enableSkillCommands": true
}
```
@ -285,12 +262,6 @@ cd /path/to/brave-search && npm install
\`\`\`
```
## Compatibility
**Claude Code**: Pi reads skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored.
**Codex CLI**: Pi reads skills from `~/.codex/skills/` recursively. Hidden files/directories and symlinks are skipped.
## Skill Repositories
For inspiration and ready-to-use skills:
@ -305,13 +276,4 @@ CLI:
pi --no-skills
```
Settings (`~/.pi/agent/settings.json`):
```json
{
"skills": {
"enabled": false
}
}
```
Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`).
Use `--no-skills` to disable automatic discovery while keeping explicit `--skill` paths.

View file

@ -5,11 +5,11 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { AuthStorage, createAgentSession, ModelRegistry } from "@mariozechner/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
// Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5");

View file

@ -4,12 +4,18 @@
* Shows how to replace or modify the default system prompt.
*/
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Option 1: Replace prompt entirely
const { session: session1 } = await createAgentSession({
systemPrompt: `You are a helpful assistant that speaks like a pirate.
const loader1 = new DefaultResourceLoader({
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
Always end responses with "Arrr!"`,
appendSystemPromptOverride: () => [],
});
await loader1.reload();
const { session: session1 } = await createAgentSession({
resourceLoader: loader1,
sessionManager: SessionManager.inMemory(),
});
@ -23,13 +29,17 @@ console.log("=== Replace prompt ===");
await session1.prompt("What is 2 + 2?");
console.log("\n");
// Option 2: Modify default prompt (receives default, returns modified)
const { session: session2 } = await createAgentSession({
systemPrompt: (defaultPrompt) => `${defaultPrompt}
// Option 2: Append instructions to the default prompt
const loader2 = new DefaultResourceLoader({
appendSystemPromptOverride: (base) => [
...base,
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",
],
});
await loader2.reload();
## Additional Instructions
- Always be concise
- Use bullet points when listing things`,
const { session: session2 } = await createAgentSession({
resourceLoader: loader2,
sessionManager: SessionManager.inMemory(),
});

View file

@ -5,20 +5,7 @@
* Discover, filter, merge, or replace them.
*/
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const { skills: allSkills, warnings } = discoverSkills();
console.log(
"Discovered skills:",
allSkills.map((s) => s.name),
);
if (warnings.length > 0) {
console.log("Warnings:", warnings);
}
// Filter to specific skills
const filteredSkills = allSkills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
import { createAgentSession, DefaultResourceLoader, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
// Or define custom skills inline
const customSkill: Skill = {
@ -29,19 +16,30 @@ const customSkill: Skill = {
source: "custom",
};
// Use filtered + custom skills
const loader = new DefaultResourceLoader({
skillsOverride: (current) => {
const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
return {
skills: [...filteredSkills, customSkill],
diagnostics: current.diagnostics,
};
},
});
await loader.reload();
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const discovered = loader.getSkills();
console.log(
"Discovered skills:",
discovered.skills.map((s) => s.name),
);
if (discovered.diagnostics.length > 0) {
console.log("Warnings:", discovered.diagnostics);
}
await createAgentSession({
skills: [...filteredSkills, customSkill],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${filteredSkills.length + 1} skills`);
// To disable all skills:
// skills: []
// To use discovery with filtering via settings:
// discoverSkills(process.cwd(), undefined, {
// ignoredSkills: ["browser-tools"], // glob patterns to exclude
// includeSkills: ["brave-*"], // glob patterns to include (empty = all)
// })
console.log("Session created with filtered skills");

View file

@ -13,16 +13,25 @@
* export default function (pi: ExtensionAPI) { ... }
*/
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Extensions are discovered automatically from standard locations.
// You can also add paths via:
// 1. settings.json: { "extensions": ["./my-extension.ts"] }
// 2. additionalExtensionPaths option (see below)
// You can also add paths via settings.json or DefaultResourceLoader options.
// To add additional extension paths beyond discovery:
const { session } = await createAgentSession({
const resourceLoader = new DefaultResourceLoader({
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
extensionFactories: [
(pi) => {
pi.on("agent_start", () => {
console.log("[Inline Extension] Agent starting");
});
},
],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
resourceLoader,
sessionManager: SessionManager.inMemory(),
});

View file

@ -4,33 +4,36 @@
* Context files provide project-specific instructions loaded into the system prompt.
*/
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files walking up from cwd
const discovered = discoverContextFiles();
console.log("Discovered context files:");
for (const file of discovered) {
console.log(` - ${file.path} (${file.content.length} chars)`);
}
// Use custom context files
await createAgentSession({
contextFiles: [
...discovered,
{
path: "/virtual/AGENTS.md",
content: `# Project Guidelines
const loader = new DefaultResourceLoader({
agentsFilesOverride: (current) => ({
agentsFiles: [
...current.agentsFiles,
{
path: "/virtual/AGENTS.md",
content: `# Project Guidelines
## Code Style
- Use TypeScript strict mode
- No any types
- Prefer const over let`,
},
],
},
],
}),
});
await loader.reload();
// Discover AGENTS.md files walking up from cwd
const discovered = loader.getAgentsFiles().agentsFiles;
console.log("Discovered context files:");
for (const file of discovered) {
console.log(` - ${file.path} (${file.content.length} chars)`);
}
await createAgentSession({
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} context files`);
// Disable context files:
// contextFiles: []

View file

@ -6,18 +6,11 @@
import {
createAgentSession,
discoverPromptTemplates,
DefaultResourceLoader,
type PromptTemplate,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = discoverPromptTemplates();
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
// Define custom templates
const deployTemplate: PromptTemplate = {
name: "deploy",
@ -30,13 +23,24 @@ const deployTemplate: PromptTemplate = {
3. Deploy: npm run deploy`,
};
// Use discovered + custom templates
const loader = new DefaultResourceLoader({
promptsOverride: (current) => ({
prompts: [...current.prompts, deployTemplate],
diagnostics: current.diagnostics,
}),
});
await loader.reload();
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = loader.getPrompts().prompts;
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
await createAgentSession({
promptTemplates: [...discovered, deployTemplate],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} prompt templates`);
// Disable prompt templates:
// promptTemplates: []

View file

@ -4,19 +4,12 @@
* Configure API key resolution via AuthStorage and ModelRegistry.
*/
import {
AuthStorage,
createAgentSession,
discoverAuthStorage,
discoverModels,
ModelRegistry,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
// Default: AuthStorage uses ~/.pi/agent/auth.json
// ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
await createAgentSession({
sessionManager: SessionManager.inMemory(),

View file

@ -4,11 +4,11 @@
* Override settings using SettingsManager.
*/
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
// Load current settings (merged global + project)
const settings = loadSettings();
console.log("Current settings:", JSON.stringify(settings, null, 2));
const settingsManagerFromDisk = SettingsManager.create();
console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
// Override specific settings
const settingsManager = SettingsManager.create();

View file

@ -13,8 +13,10 @@ import {
AuthStorage,
createAgentSession,
createBashTool,
createExtensionRuntime,
createReadTool,
ModelRegistry,
type ResourceLoader,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
@ -42,6 +44,18 @@ const settingsManager = SettingsManager.inMemory({
// When using a custom cwd with explicit tools, use the factory functions
const cwd = process.cwd();
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => `You are a minimal assistant.
Available: read, bash. Be concise.`,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({
cwd,
agentDir: "/tmp/my-agent",
@ -49,15 +63,9 @@ const { session } = await createAgentSession({
thinkingLevel: "off",
authStorage,
modelRegistry,
systemPrompt: `You are a minimal assistant.
Available: read, bash. Be concise.`,
resourceLoader,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
// Pass empty array to disable extension discovery, or provide inline factories
extensions: [],
skills: [],
contextFiles: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(),
settingsManager,
});

View file

@ -33,24 +33,18 @@ import { getModel } from "@mariozechner/pi-ai";
import {
AuthStorage,
createAgentSession,
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverExtensions,
discoverContextFiles,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
DefaultResourceLoader,
ModelRegistry,
SessionManager,
SettingsManager,
codingTools,
readOnlyTools,
readTool, bashTool, editTool, writeTool,
} from "@mariozechner/pi-coding-agent";
// Auth and models setup
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
// Minimal
const { session } = await createAgentSession({ authStorage, modelRegistry });
@ -60,11 +54,11 @@ const model = getModel("anthropic", "claude-opus-4-5");
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
// Modify prompt
const { session } = await createAgentSession({
systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
authStorage,
modelRegistry,
const loader = new DefaultResourceLoader({
systemPromptOverride: (base) => `${base}\n\nBe concise.`,
});
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
// Read-only
const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry });
@ -81,18 +75,24 @@ const customAuth = new AuthStorage("/my/app/auth.json");
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const customRegistry = new ModelRegistry(customAuth);
const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () => "You are helpful.",
extensionFactories: [myExtension],
skillsOverride: () => ({ skills: [], diagnostics: [] }),
agentsFilesOverride: () => ({ agentsFiles: [] }),
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
});
await resourceLoader.reload();
const { session } = await createAgentSession({
model,
authStorage: customAuth,
modelRegistry: customRegistry,
systemPrompt: "You are helpful.",
resourceLoader,
tools: [readTool, bashTool],
customTools: [{ tool: myTool }],
extensions: [{ factory: myExtension }],
skills: [],
contextFiles: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
});
// Run prompts
@ -108,23 +108,17 @@ await session.prompt("Hello");
| Option | Default | Description |
|--------|---------|-------------|
| `authStorage` | `discoverAuthStorage()` | Credential storage |
| `modelRegistry` | `discoverModels(authStorage)` | Model registry |
| `authStorage` | `new AuthStorage()` | Credential storage |
| `modelRegistry` | `new ModelRegistry(authStorage)` | Model registry |
| `cwd` | `process.cwd()` | Working directory |
| `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use |
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
| `systemPrompt` | Discovered | String or `(default) => modified` |
| `tools` | `codingTools` | Built-in tools |
| `customTools` | Discovered | Replaces discovery |
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
| `extensions` | Discovered | Replaces discovery |
| `additionalExtensionPaths` | `[]` | Merge with discovery |
| `skills` | Discovered | Skills for prompt |
| `contextFiles` | Discovered | AGENTS.md files |
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
| `customTools` | `[]` | Additional tool definitions |
| `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes |
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
| `settingsManager` | From agentDir | Settings overrides |
| `settingsManager` | `SettingsManager.create(cwd, agentDir)` | Settings overrides |
## Events

View file

@ -33,6 +33,10 @@ export interface Args {
export?: string;
noSkills?: boolean;
skills?: string[];
promptTemplates?: string[];
noPromptTemplates?: boolean;
themes?: string[];
noThemes?: boolean;
listModels?: string | true;
messages: string[];
fileArgs: string[];
@ -122,11 +126,21 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
result.extensions.push(args[++i]);
} else if (arg === "--no-extensions") {
result.noExtensions = true;
} else if (arg === "--skill" && i + 1 < args.length) {
result.skills = result.skills ?? [];
result.skills.push(args[++i]);
} else if (arg === "--prompt-template" && i + 1 < args.length) {
result.promptTemplates = result.promptTemplates ?? [];
result.promptTemplates.push(args[++i]);
} else if (arg === "--theme" && i + 1 < args.length) {
result.themes = result.themes ?? [];
result.themes.push(args[++i]);
} else if (arg === "--no-skills") {
result.noSkills = true;
} else if (arg === "--skills" && i + 1 < args.length) {
// Comma-separated glob patterns for skill filtering
result.skills = args[++i].split(",").map((s) => s.trim());
} else if (arg === "--no-prompt-templates") {
result.noPromptTemplates = true;
} else if (arg === "--no-themes") {
result.noThemes = true;
} else if (arg === "--list-models") {
// Check if next arg is a search pattern (not a flag or file arg)
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
@ -162,6 +176,12 @@ export function printHelp(): void {
${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} update [source] Update installed extensions (skips pinned sources)
${APP_NAME} list List installed extensions from settings
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
--model <id> Model ID (default: gemini-2.5-flash)
@ -183,8 +203,12 @@ ${chalk.bold("Options:")}
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--extension, -e <path> Load an extension file (can be used multiple times)
--no-extensions Disable extension discovery (explicit -e paths still work)
--skill <path> Load a skill file or directory (can be used multiple times)
--no-skills Disable skills discovery and loading
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)
--no-prompt-templates Disable prompt template discovery and loading
--theme <path> Load a theme file or directory (can be used multiple times)
--no-themes Disable theme discovery and loading
--export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search)
--help, -h Show this help

View file

@ -40,25 +40,35 @@ import {
} from "./compaction/index.js";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import type {
ContextUsage,
import {
type ContextUsage,
type ExtensionCommandContextActions,
type ExtensionErrorListener,
ExtensionRunner,
InputSource,
SessionBeforeCompactResult,
SessionBeforeForkResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
TreePreparation,
TurnEndEvent,
TurnStartEvent,
type ExtensionUIContext,
type InputSource,
type SessionBeforeCompactResult,
type SessionBeforeForkResult,
type SessionBeforeSwitchResult,
type SessionBeforeTreeResult,
type ShutdownHandler,
type ToolDefinition,
type TreePreparation,
type TurnEndEvent,
type TurnStartEvent,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
import type { ResourceLoader } from "./resource-loader.js";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
import type { SettingsManager } from "./settings-manager.js";
import type { Skill, SkillWarning } from "./skills.js";
import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js";
import { createAllTools } from "./tools/index.js";
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
@ -85,23 +95,28 @@ export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
settingsManager: SettingsManager;
cwd: string;
/** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** File-based prompt templates for expansion */
promptTemplates?: PromptTemplate[];
/** Extension runner (created in sdk.ts with wrapped tools) */
extensionRunner?: ExtensionRunner;
/** Loaded skills (already discovered by SDK) */
skills?: Skill[];
/** Skill loading warnings (already captured by SDK) */
skillWarnings?: SkillWarning[];
skillsSettings?: Required<SkillsSettings>;
/** Resource loader for skills, prompts, themes, context files, system prompt */
resourceLoader: ResourceLoader;
/** SDK custom tools registered outside extensions */
customTools?: ToolDefinition[];
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Tool registry for extension getTools/setTools - maps name to tool */
toolRegistry?: Map<string, AgentTool>;
/** Function to rebuild system prompt when tools change */
rebuildSystemPrompt?: (toolNames: string[]) => string;
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[];
/** Override base tools (useful for custom runtimes). */
baseToolsOverride?: Record<string, AgentTool>;
/** Mutable ref used by Agent to access the current ExtensionRunner */
extensionRunnerRef?: { current?: ExtensionRunner };
}
export interface ExtensionBindings {
uiContext?: ExtensionUIContext;
commandContextActions?: ExtensionCommandContextActions;
shutdownHandler?: ShutdownHandler;
onError?: ExtensionErrorListener;
}
/** Options for AgentSession.prompt() */
@ -163,7 +178,6 @@ export class AgentSession {
readonly settingsManager: SettingsManager;
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
private _promptTemplates: PromptTemplate[];
// Event subscription state
private _unsubscribeAgent?: () => void;
@ -197,40 +211,49 @@ export class AgentSession {
private _extensionRunner: ExtensionRunner | undefined = undefined;
private _turnIndex = 0;
private _skills: Skill[];
private _skillWarnings: SkillWarning[];
private _skillsSettings: Required<SkillsSettings> | undefined;
private _resourceLoader: ResourceLoader;
private _customTools: ToolDefinition[];
private _baseToolRegistry: Map<string, AgentTool> = new Map();
private _cwd: string;
private _extensionRunnerRef?: { current?: ExtensionRunner };
private _initialActiveToolNames?: string[];
private _baseToolsOverride?: Record<string, AgentTool>;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionShutdownHandler?: ShutdownHandler;
private _extensionErrorListeners = new Set<ExtensionErrorListener>();
private _extensionErrorUnsubscribers: Array<() => void> = [];
// Model registry for API key resolution
private _modelRegistry: ModelRegistry;
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool>;
// Function to rebuild system prompt when tools change
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
private _toolRegistry: Map<string, AgentTool> = new Map();
// Base system prompt (without extension appends) - used to apply fresh appends each turn
private _baseSystemPrompt: string;
private _baseSystemPrompt = "";
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? [];
this._promptTemplates = config.promptTemplates ?? [];
this._extensionRunner = config.extensionRunner;
this._skills = config.skills ?? [];
this._skillWarnings = config.skillWarnings ?? [];
this._skillsSettings = config.skillsSettings;
this._resourceLoader = config.resourceLoader;
this._customTools = config.customTools ?? [];
this._cwd = config.cwd;
this._modelRegistry = config.modelRegistry;
this._toolRegistry = config.toolRegistry ?? new Map();
this._rebuildSystemPrompt = config.rebuildSystemPrompt;
this._baseSystemPrompt = config.agent.state.systemPrompt;
this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames;
this._baseToolsOverride = config.baseToolsOverride;
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._buildRuntime({
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
}
/** Model registry for API key resolution and model discovery */
@ -502,10 +525,8 @@ export class AgentSession {
this.agent.setTools(tools);
// Rebuild base system prompt with new tool set
if (this._rebuildSystemPrompt) {
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
/** Whether auto-compaction is currently running */
@ -550,7 +571,26 @@ export class AgentSession {
/** File-based prompt templates */
get promptTemplates(): ReadonlyArray<PromptTemplate> {
return this._promptTemplates;
return this._resourceLoader.getPrompts().prompts;
}
private _rebuildSystemPrompt(toolNames: string[]): string {
const validToolNames = toolNames.filter((name) => this._baseToolRegistry.has(name));
const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
const appendSystemPrompt =
loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
const loadedSkills = this._resourceLoader.getSkills().skills;
const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
return buildSystemPrompt({
cwd: this._cwd,
skills: loadedSkills,
contextFiles: loadedContextFiles,
customPrompt: loaderSystemPrompt,
appendSystemPrompt,
selectedTools: validToolNames,
});
}
// =========================================================================
@ -601,7 +641,7 @@ export class AgentSession {
let expandedText = currentText;
if (expandPromptTemplates) {
expandedText = this._expandSkillCommand(expandedText);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
}
// If streaming, queue via steer() or followUp() based on option
@ -750,7 +790,7 @@ export class AgentSession {
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skill = this._skills.find((s) => s.name === skillName);
const skill = this.skills.find((s) => s.name === skillName);
if (!skill) return text; // Unknown skill, pass through
try {
@ -784,7 +824,7 @@ export class AgentSession {
// Expand skill commands and prompt templates
let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
await this._queueSteer(expandedText);
}
@ -803,7 +843,7 @@ export class AgentSession {
// Expand skill commands and prompt templates
let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
await this._queueFollowUp(expandedText);
}
@ -891,6 +931,8 @@ export class AgentSession {
message.display,
message.details,
);
this._emit({ type: "message_start", message: appMessage });
this._emit({ type: "message_end", message: appMessage });
}
}
@ -963,18 +1005,21 @@ export class AgentSession {
return this._followUpMessages;
}
get skillsSettings(): Required<SkillsSettings> | undefined {
return this._skillsSettings;
}
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
/** Skills loaded by resource loader */
get skills(): readonly Skill[] {
return this._skills;
return this._resourceLoader.getSkills().skills;
}
/** Skill loading warnings captured by SDK */
/** Skill loading warnings captured by resource loader */
get skillWarnings(): readonly SkillWarning[] {
return this._skillWarnings;
return this._resourceLoader.getSkills().diagnostics.map((diagnostic) => ({
skillPath: diagnostic.path ?? "<unknown>",
message: diagnostic.message,
}));
}
get resourceLoader(): ResourceLoader {
return this._resourceLoader;
}
/**
@ -1588,6 +1633,219 @@ export class AgentSession {
return this.settingsManager.getCompactionEnabled();
}
async bindExtensions(bindings: ExtensionBindings): Promise<void> {
if (bindings.uiContext !== undefined) {
this._extensionUIContext = bindings.uiContext;
}
if (bindings.commandContextActions !== undefined) {
this._extensionCommandContextActions = bindings.commandContextActions;
}
if (bindings.shutdownHandler !== undefined) {
this._extensionShutdownHandler = bindings.shutdownHandler;
}
if (bindings.onError) {
this._extensionErrorListeners.add(bindings.onError);
}
if (this._extensionRunner) {
this._applyExtensionBindings(this._extensionRunner);
await this._extensionRunner.emit({ type: "session_start" });
}
}
private _applyExtensionBindings(runner: ExtensionRunner): void {
runner.setUIContext(this._extensionUIContext);
runner.bindCommandContext(this._extensionCommandContextActions);
for (const unsubscribe of this._extensionErrorUnsubscribers) {
unsubscribe();
}
this._extensionErrorUnsubscribers = [];
for (const listener of this._extensionErrorListeners) {
this._extensionErrorUnsubscribers.push(runner.onError(listener));
}
}
private _bindExtensionCore(runner: ExtensionRunner): void {
runner.bindCore(
{
sendMessage: (message, options) => {
this.sendCustomMessage(message, options).catch((err) => {
runner.emitError({
extensionPath: "<runtime>",
event: "send_message",
error: err instanceof Error ? err.message : String(err),
});
});
},
sendUserMessage: (content, options) => {
this.sendUserMessage(content, options).catch((err) => {
runner.emitError({
extensionPath: "<runtime>",
event: "send_user_message",
error: err instanceof Error ? err.message : String(err),
});
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => this.getActiveToolNames(),
getAllTools: () => this.getAllTools(),
setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await this.modelRegistry.getApiKey(model);
if (!key) return false;
await this.setModel(model);
return true;
},
getThinkingLevel: () => this.thinkingLevel,
setThinkingLevel: (level) => this.setThinkingLevel(level),
},
{
getModel: () => this.model,
isIdle: () => !this.isStreaming,
abort: () => this.abort(),
hasPendingMessages: () => this.pendingMessageCount > 0,
shutdown: () => {
this._extensionShutdownHandler?.();
},
getContextUsage: () => this.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
);
}
private _buildRuntime(options: {
activeToolNames?: string[];
flagValues?: Map<string, boolean | string>;
includeAllExtensionTools?: boolean;
}): void {
const autoResizeImages = this.settingsManager.getImageAutoResize();
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
const baseTools = this._baseToolsOverride
? this._baseToolsOverride
: createAllTools(this._cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
const extensionsResult = this._resourceLoader.getExtensions();
if (options.flagValues) {
for (const [name, value] of options.flagValues) {
extensionsResult.runtime.flagValues.set(name, value);
}
}
const hasExtensions = extensionsResult.extensions.length > 0;
const hasCustomTools = this._customTools.length > 0;
this._extensionRunner =
hasExtensions || hasCustomTools
? new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
this._cwd,
this.sessionManager,
this._modelRegistry,
)
: undefined;
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
}
if (this._extensionRunner) {
this._bindExtensionCore(this._extensionRunner);
this._applyExtensionBindings(this._extensionRunner);
}
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [
...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
];
const wrappedExtensionTools = this._extensionRunner
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
const toolRegistry = new Map(this._baseToolRegistry);
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
const defaultActiveToolNames = this._baseToolsOverride
? Object.keys(this._baseToolsOverride)
: ["read", "bash", "edit", "write"];
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
const activeToolNameSet = new Set<string>(baseActiveToolNames);
if (options.includeAllExtensionTools) {
for (const tool of wrappedExtensionTools as AgentTool[]) {
activeToolNameSet.add(tool.name);
}
}
const extensionToolNames = new Set(wrappedExtensionTools.map((tool) => tool.name));
const activeBaseTools = Array.from(activeToolNameSet)
.filter((name) => this._baseToolRegistry.has(name) && !extensionToolNames.has(name))
.map((name) => this._baseToolRegistry.get(name) as AgentTool);
const activeExtensionTools = wrappedExtensionTools.filter((tool) => activeToolNameSet.has(tool.name));
const activeToolsArray: AgentTool[] = [...activeBaseTools, ...activeExtensionTools];
if (this._extensionRunner) {
const wrappedActiveTools = wrapToolsWithExtensions(activeToolsArray, this._extensionRunner);
this.agent.setTools(wrappedActiveTools as AgentTool[]);
const wrappedAllTools = wrapToolsWithExtensions(Array.from(toolRegistry.values()), this._extensionRunner);
this._toolRegistry = new Map(wrappedAllTools.map((tool) => [tool.name, tool]));
} else {
this.agent.setTools(activeToolsArray);
this._toolRegistry = toolRegistry;
}
const systemPromptToolNames = Array.from(activeToolNameSet).filter((name) => this._baseToolRegistry.has(name));
this._baseSystemPrompt = this._rebuildSystemPrompt(systemPromptToolNames);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
async reload(): Promise<void> {
const previousFlagValues = this._extensionRunner?.getFlagValues();
await this._extensionRunner?.emit({ type: "session_shutdown" });
await this._resourceLoader.reload();
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
const hasBindings =
this._extensionUIContext ||
this._extensionCommandContextActions ||
this._extensionShutdownHandler ||
this._extensionErrorListeners.size > 0;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });
}
}
// =========================================================================
// Auto-Retry
// =========================================================================

View file

@ -18,8 +18,9 @@ import {
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname } from "path";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
export type ApiKeyCredential = {
type: "api_key";
@ -42,7 +43,7 @@ export class AuthStorage {
private runtimeOverrides: Map<string, string> = new Map();
private fallbackResolver?: (provider: string) => string | undefined;
constructor(private authPath: string) {
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {
this.reload();
}

View file

@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { type ChildProcess, spawn } from "child_process";
import stripAnsi from "strip-ansi";
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
@ -63,6 +63,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
const { shell, args } = getShellConfig();
const child: ChildProcess = spawn(shell, [...args, command], {
detached: true,
env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});

View file

@ -101,7 +101,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Create a runtime with throwing stubs for action methods.
* Runner.initialize() replaces these with real implementations.
* Runner.bindCore() replaces these with real implementations.
*/
export function createExtensionRuntime(): ExtensionRuntime {
const notInitialized = () => {
@ -246,6 +246,7 @@ function createExtensionAPI(
async function loadExtensionModule(extensionPath: string) {
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
// Also disable tryNative so jiti handles ALL imports (not just the entry point)
// In Node.js/dev: use aliases to resolve to node_modules paths
@ -347,6 +348,7 @@ interface PiManifest {
extensions?: string[];
themes?: string[];
skills?: string[];
prompts?: string[];
}
function readPiManifest(packageJsonPath: string): PiManifest | null {

View file

@ -178,12 +178,7 @@ export class ExtensionRunner {
this.modelRegistry = modelRegistry;
}
initialize(
actions: ExtensionActions,
contextActions: ExtensionContextActions,
commandContextActions?: ExtensionCommandContextActions,
uiContext?: ExtensionUIContext,
): void {
bindCore(actions: ExtensionActions, contextActions: ExtensionContextActions): void {
// Copy actions into the shared runtime (all extension APIs reference this)
this.runtime.sendMessage = actions.sendMessage;
this.runtime.sendUserMessage = actions.sendUserMessage;
@ -206,14 +201,24 @@ export class ExtensionRunner {
this.shutdownHandler = contextActions.shutdown;
this.getContextUsageFn = contextActions.getContextUsage;
this.compactFn = contextActions.compact;
}
// Command context actions (optional, only for interactive mode)
if (commandContextActions) {
this.waitForIdleFn = commandContextActions.waitForIdle;
this.newSessionHandler = commandContextActions.newSession;
this.forkHandler = commandContextActions.fork;
this.navigateTreeHandler = commandContextActions.navigateTree;
bindCommandContext(actions?: ExtensionCommandContextActions): void {
if (actions) {
this.waitForIdleFn = actions.waitForIdle;
this.newSessionHandler = actions.newSession;
this.forkHandler = actions.fork;
this.navigateTreeHandler = actions.navigateTree;
return;
}
this.waitForIdleFn = async () => {};
this.newSessionHandler = async () => ({ cancelled: false });
this.forkHandler = async () => ({ cancelled: false });
this.navigateTreeHandler = async () => ({ cancelled: false });
}
setUIContext(uiContext?: ExtensionUIContext): void {
this.uiContext = uiContext ?? noOpUIContext;
}
@ -265,6 +270,10 @@ export class ExtensionRunner {
this.runtime.flagValues.set(name, value);
}
getFlagValues(): Map<string, boolean | string> {
return new Map(this.runtime.flagValues);
}
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
@ -351,7 +360,7 @@ export class ExtensionRunner {
/**
* Request a graceful shutdown. Called by extension tools and event handlers.
* The actual shutdown behavior is provided by the mode via initialize().
* The actual shutdown behavior is provided by the mode via bindExtensions().
*/
shutdown(): void {
this.shutdownHandler();
@ -359,7 +368,7 @@ export class ExtensionRunner {
/**
* Create an ExtensionContext for use in event handlers and tool execution.
* Context values are resolved at call time, so changes via initialize() are reflected.
* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
*/
createContext(): ExtensionContext {
const getModel = this.getModel;

View file

@ -85,6 +85,11 @@ export class FooterDataProvider {
}
}
/** Internal: clear extension statuses */
clearExtensionStatuses(): void {
this.extensionStatuses.clear();
}
/** Internal: cleanup */
dispose(): void {
if (this.gitWatcher) {

View file

@ -15,6 +15,8 @@ import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { execSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
import type { AuthStorage } from "./auth-storage.js";
const Ajv = (AjvModule as any).default || AjvModule;
@ -159,7 +161,7 @@ export class ModelRegistry {
constructor(
readonly authStorage: AuthStorage,
private modelsJsonPath: string | undefined = undefined,
private modelsJsonPath: string | undefined = join(getAgentDir(), "models.json"),
) {
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver((provider) => {

View file

@ -0,0 +1,626 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { CONFIG_DIR_NAME } from "../config.js";
import type { SettingsManager } from "./settings-manager.js";
export interface ResolvedPaths {
extensions: string[];
skills: string[];
prompts: string[];
themes: string[];
}
export type MissingSourceAction = "install" | "skip" | "error";
export interface ProgressEvent {
type: "start" | "progress" | "complete" | "error";
action: "install" | "remove" | "update" | "clone" | "pull";
source: string;
message?: string;
}
export type ProgressCallback = (event: ProgressEvent) => void;
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
update(source?: string): Promise<void>;
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
setProgressCallback(callback: ProgressCallback | undefined): void;
}
interface PackageManagerOptions {
cwd: string;
agentDir: string;
settingsManager: SettingsManager;
}
type SourceScope = "global" | "project" | "temporary";
type NpmSource = {
type: "npm";
spec: string;
name: string;
pinned: boolean;
};
type GitSource = {
type: "git";
repo: string;
host: string;
path: string;
ref?: string;
pinned: boolean;
};
type LocalSource = {
type: "local";
path: string;
};
type ParsedSource = NpmSource | GitSource | LocalSource;
interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
interface ResourceAccumulator {
extensions: Set<string>;
skills: Set<string>;
prompts: Set<string>;
themes: Set<string>;
}
export class DefaultPackageManager implements PackageManager {
private cwd: string;
private agentDir: string;
private settingsManager: SettingsManager;
private globalNpmRoot: string | undefined;
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.settingsManager = options.settingsManager;
this.ensureGitIgnoreDirs();
}
setProgressCallback(callback: ProgressCallback | undefined): void {
this.progressCallback = callback;
}
private emitProgress(event: ProgressEvent): void {
this.progressCallback?.(event);
}
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const extensionSources: Array<{ source: string; scope: SourceScope }> = [];
for (const source of globalSettings.extensions ?? []) {
extensionSources.push({ source, scope: "global" });
}
for (const source of projectSettings.extensions ?? []) {
extensionSources.push({ source, scope: "project" });
}
await this.resolveExtensionSourcesInternal(extensionSources, accumulator, onMissing);
for (const skill of projectSettings.skills ?? []) {
this.addPath(accumulator.skills, this.resolvePath(skill));
}
for (const skill of globalSettings.skills ?? []) {
this.addPath(accumulator.skills, this.resolvePath(skill));
}
for (const prompt of projectSettings.prompts ?? []) {
this.addPath(accumulator.prompts, this.resolvePath(prompt));
}
for (const prompt of globalSettings.prompts ?? []) {
this.addPath(accumulator.prompts, this.resolvePath(prompt));
}
for (const theme of projectSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme));
}
for (const theme of globalSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme));
}
return this.toResolvedPaths(accumulator);
}
async resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global";
const extensionSources = sources.map((source) => ({ source, scope }));
await this.resolveExtensionSourcesInternal(extensionSources, accumulator);
return this.toResolvedPaths(accumulator);
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
this.emitProgress({ type: "start", action: "install", source, message: `Installing ${source}...` });
try {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
this.emitProgress({ type: "complete", action: "install", source });
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope);
this.emitProgress({ type: "complete", action: "install", source });
return;
}
throw new Error(`Unsupported install source: ${source}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "install", source, message });
throw error;
}
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
this.emitProgress({ type: "start", action: "remove", source, message: `Removing ${source}...` });
try {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
this.emitProgress({ type: "complete", action: "remove", source });
return;
}
if (parsed.type === "git") {
await this.removeGit(parsed, scope);
this.emitProgress({ type: "complete", action: "remove", source });
return;
}
throw new Error(`Unsupported remove source: ${source}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "remove", source, message });
throw error;
}
}
async update(source?: string): Promise<void> {
if (source) {
await this.updateSourceForScope(source, "global");
await this.updateSourceForScope(source, "project");
return;
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
for (const extension of globalSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "global");
}
for (const extension of projectSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "project");
}
}
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
if (parsed.pinned) return;
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
try {
await this.installNpm(parsed, scope, false);
this.emitProgress({ type: "complete", action: "update", source });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "update", source, message });
throw error;
}
return;
}
if (parsed.type === "git") {
if (parsed.pinned) return;
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
try {
await this.updateGit(parsed, scope);
this.emitProgress({ type: "complete", action: "update", source });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "update", source, message });
throw error;
}
return;
}
}
private async resolveExtensionSourcesInternal(
sources: Array<{ source: string; scope: SourceScope }>,
accumulator: ResourceAccumulator,
onMissing?: (source: string) => Promise<MissingSourceAction>,
): Promise<void> {
for (const { source, scope } of sources) {
const parsed = this.parseSource(source);
if (parsed.type === "local") {
this.resolveLocalExtensionSource(parsed, accumulator);
continue;
}
const installMissing = async (): Promise<boolean> => {
if (!onMissing) {
await this.installParsedSource(parsed, scope);
return true;
}
const action = await onMissing(source);
if (action === "skip") return false;
if (action === "error") throw new Error(`Missing source: ${source}`);
await this.installParsedSource(parsed, scope);
return true;
};
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, scope);
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
}
this.collectPackageResources(installedPath, accumulator);
continue;
}
if (parsed.type === "git") {
const installedPath = this.getGitInstallPath(parsed, scope);
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
}
this.collectPackageResources(installedPath, accumulator);
}
}
}
private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void {
const resolved = this.resolvePath(source.path);
if (!existsSync(resolved)) {
return;
}
try {
const stats = statSync(resolved);
if (stats.isFile()) {
this.addPath(accumulator.extensions, resolved);
return;
}
if (stats.isDirectory()) {
const resources = this.collectPackageResources(resolved, accumulator);
if (!resources) {
this.addPath(accumulator.extensions, resolved);
}
}
} catch {
return;
}
}
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, scope === "temporary");
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope);
return;
}
}
private parseSource(source: string): ParsedSource {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const { name, version } = this.parseNpmSpec(spec);
return {
type: "npm",
spec,
name,
pinned: Boolean(version),
};
}
// Accept git: prefix or raw URLs (https://github.com/..., github.com/...)
if (source.startsWith("git:") || this.looksLikeGitUrl(source)) {
const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source;
const [repo, ref] = repoSpec.split("@");
const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, "");
const parts = normalized.split("/");
const host = parts.shift() ?? "";
const repoPath = parts.join("/");
return {
type: "git",
repo: normalized,
host,
path: repoPath,
ref,
pinned: Boolean(ref),
};
}
return { type: "local", path: source };
}
private looksLikeGitUrl(source: string): boolean {
// Match URLs like https://github.com/..., github.com/..., gitlab.com/...
const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
const normalized = source.replace(/^https?:\/\//, "");
return gitHosts.some((host) => normalized.startsWith(`${host}/`));
}
private parseNpmSpec(spec: string): { name: string; version?: string } {
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
if (!match) {
return { name: spec };
}
const name = match[1] ?? spec;
const version = match[2];
return { name, version };
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "global" && !temporary) {
await this.runCommand("npm", ["install", "-g", source.spec]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
}
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "global") {
await this.runCommand("npm", ["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) {
return;
}
await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]);
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
return;
}
mkdirSync(dirname(targetDir), { recursive: true });
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}
// Install npm dependencies if package.json exists
const packageJsonPath = join(targetDir, "package.json");
if (existsSync(packageJsonPath)) {
await this.runCommand("npm", ["install"], { cwd: targetDir });
}
}
private async updateGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (!existsSync(targetDir)) {
await this.installGit(source, scope);
return;
}
await this.runCommand("git", ["pull"], { cwd: targetDir });
// Reinstall npm dependencies if package.json exists (in case deps changed)
const packageJsonPath = join(targetDir, "package.json");
if (existsSync(packageJsonPath)) {
await this.runCommand("npm", ["install"], { cwd: targetDir });
}
}
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (!existsSync(targetDir)) return;
rmSync(targetDir, { recursive: true, force: true });
}
private ensureNpmProject(installRoot: string): void {
if (!existsSync(installRoot)) {
mkdirSync(installRoot, { recursive: true });
}
this.ensureGitIgnore(installRoot);
const packageJsonPath = join(installRoot, "package.json");
if (!existsSync(packageJsonPath)) {
const pkgJson = { name: "pi-extensions", private: true };
writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
}
}
private ensureGitIgnoreDirs(): void {
this.ensureGitIgnore(join(this.agentDir, "git"));
this.ensureGitIgnore(join(this.agentDir, "npm"));
this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "git"));
this.ensureGitIgnore(join(this.cwd, CONFIG_DIR_NAME, "npm"));
}
private ensureGitIgnore(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const ignorePath = join(dir, ".gitignore");
if (!existsSync(ignorePath)) {
writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8");
}
}
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
if (temporary) {
return this.getTemporaryDir("npm");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.getGlobalNpmRoot(), "..");
}
private getGlobalNpmRoot(): string {
if (this.globalNpmRoot) {
return this.globalNpmRoot;
}
const result = this.runCommandSync("npm", ["root", "-g"]);
this.globalNpmRoot = result.trim();
return this.globalNpmRoot;
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.getGlobalNpmRoot(), source.name);
}
private getGitInstallPath(source: GitSource, scope: SourceScope): string {
if (scope === "temporary") {
return this.getTemporaryDir(`git-${source.host}`, source.path);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
}
return join(this.agentDir, "git", source.host, source.path);
}
private getTemporaryDir(prefix: string, suffix?: string): string {
const hash = createHash("sha256")
.update(`${prefix}-${suffix ?? ""}`)
.digest("hex")
.slice(0, 8);
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
}
private collectPackageResources(packageRoot: string, accumulator: ResourceAccumulator): boolean {
const manifest = this.readPiManifest(packageRoot);
if (manifest) {
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
return true;
}
const extensionsDir = join(packageRoot, "extensions");
const skillsDir = join(packageRoot, "skills");
const promptsDir = join(packageRoot, "prompts");
const themesDir = join(packageRoot, "themes");
const hasAnyDir =
existsSync(extensionsDir) || existsSync(skillsDir) || existsSync(promptsDir) || existsSync(themesDir);
if (!hasAnyDir) {
return false;
}
if (existsSync(extensionsDir)) {
this.addPath(accumulator.extensions, extensionsDir);
}
if (existsSync(skillsDir)) {
this.addPath(accumulator.skills, skillsDir);
}
if (existsSync(promptsDir)) {
this.addPath(accumulator.prompts, promptsDir);
}
if (existsSync(themesDir)) {
this.addPath(accumulator.themes, themesDir);
}
return true;
}
private readPiManifest(packageRoot: string): PiManifest | null {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { pi?: PiManifest };
return pkg.pi ?? null;
} catch {
return null;
}
}
private addManifestEntries(entries: string[] | undefined, root: string, target: Set<string>): void {
if (!entries) return;
for (const entry of entries) {
const resolved = resolve(root, entry);
this.addPath(target, resolved);
}
}
private addPath(set: Set<string>, value: string): void {
if (!value) return;
set.add(value);
}
private createAccumulator(): ResourceAccumulator {
return {
extensions: new Set<string>(),
skills: new Set<string>(),
prompts: new Set<string>(),
themes: new Set<string>(),
};
}
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
return {
extensions: Array.from(accumulator.extensions),
skills: Array.from(accumulator.skills),
prompts: Array.from(accumulator.prompts),
themes: Array.from(accumulator.themes),
};
}
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: options?.cwd,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
} else {
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
}
});
});
}
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
}
return (result.stdout || result.stderr || "").trim();
}
}

View file

@ -1,5 +1,6 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join, resolve } from "path";
import { homedir } from "os";
import { basename, isAbsolute, join, resolve } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
@ -10,7 +11,7 @@ export interface PromptTemplate {
name: string;
description: string;
content: string;
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
source: string; // e.g., "(user)", "(project)", "(custom:my-dir)"
}
/**
@ -97,10 +98,42 @@ export function substituteArgs(content: string, args: string[]): string {
return result;
}
function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemplate | null {
try {
const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
const name = basename(filePath).replace(/\.md$/, "");
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = body.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
// Append source to description
description = description ? `${description} ${sourceLabel}` : sourceLabel;
return {
name,
description,
content: body,
source: sourceLabel,
};
} catch {
return null;
}
}
/**
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
* Scan a directory for .md files (non-recursive) and load them as prompt templates.
*/
function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] {
function loadTemplatesFromDir(dir: string, sourceLabel: string): PromptTemplate[] {
const templates: PromptTemplate[] = [];
if (!existsSync(dir)) {
@ -113,13 +146,11 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
for (const entry of entries) {
const fullPath = join(dir, entry.name);
// For symlinks, check if they point to a directory and follow them
let isDirectory = entry.isDirectory();
// For symlinks, check if they point to a file
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDirectory = stats.isDirectory();
isFile = stats.isFile();
} catch {
// Broken symlink, skip it
@ -127,52 +158,15 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
}
}
if (isDirectory) {
// Recurse into subdirectory
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir));
} else if (isFile && entry.name.endsWith(".md")) {
try {
const rawContent = readFileSync(fullPath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
const name = entry.name.slice(0, -3); // Remove .md extension
// Build source string
let sourceStr: string;
if (source === "user") {
sourceStr = subdir ? `(user:${subdir})` : "(user)";
} else {
sourceStr = subdir ? `(project:${subdir})` : "(project)";
}
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = body.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
// Append source to description
description = description ? `${description} ${sourceStr}` : sourceStr;
templates.push({
name,
description,
content: body,
source: sourceStr,
});
} catch (_error) {
// Silently skip files that can't be read
if (isFile && entry.name.endsWith(".md")) {
const template = loadTemplateFromFile(fullPath, sourceLabel);
if (template) {
templates.push(template);
}
}
}
} catch (_error) {
// Silently skip directories that can't be read
} catch {
return templates;
}
return templates;
@ -183,27 +177,71 @@ export interface LoadPromptTemplatesOptions {
cwd?: string;
/** Agent config directory for global templates. Default: from getPromptsDir() */
agentDir?: string;
/** Explicit prompt template paths (files or directories) */
promptPaths?: string[];
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolvePromptPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
function buildCustomSourceLabel(p: string): string {
const base = basename(p).replace(/\.md$/, "") || "custom";
return `(custom:${base})`;
}
/**
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
* 3. Explicit prompt paths
*/
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
const promptPaths = options.promptPaths ?? [];
const templates: PromptTemplate[] = [];
// 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
templates.push(...loadTemplatesFromDir(globalPromptsDir, "(user)"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
templates.push(...loadTemplatesFromDir(projectPromptsDir, "(project)"));
// 3. Load explicit prompt paths
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
if (!existsSync(resolvedPath)) {
continue;
}
try {
const stats = statSync(resolvedPath);
if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, buildCustomSourceLabel(resolvedPath)));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, buildCustomSourceLabel(resolvedPath));
if (template) {
templates.push(template);
}
}
} catch {
// Ignore read failures
}
}
return templates;
}

View file

@ -0,0 +1,579 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "./extensions/loader.js";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js";
import { DefaultPackageManager } from "./package-manager.js";
import type { PromptTemplate } from "./prompt-templates.js";
import { loadPromptTemplates } from "./prompt-templates.js";
import { SettingsManager } from "./settings-manager.js";
import type { Skill, SkillWarning } from "./skills.js";
import { loadSkills } from "./skills.js";
export interface ResourceDiagnostic {
type: "warning" | "error";
message: string;
path?: string;
}
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };
getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[];
reload(): Promise<void>;
}
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
if (!input) {
return undefined;
}
if (existsSync(input)) {
try {
return readFileSync(input, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
return input;
}
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
const candidates = ["AGENTS.md", "CLAUDE.md"];
for (const filename of candidates) {
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
};
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
}
}
}
return null;
}
function loadProjectContextFiles(
options: { cwd?: string; agentDir?: string } = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getAgentDir();
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
const globalContext = loadContextFileFromDir(resolvedAgentDir);
if (globalContext) {
contextFiles.push(globalContext);
seenPaths.add(globalContext.path);
}
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
export interface DefaultResourceLoaderOptions {
cwd?: string;
agentDir?: string;
settingsManager?: SettingsManager;
eventBus?: EventBus;
additionalExtensionPaths?: string[];
additionalSkillPaths?: string[];
additionalPromptTemplatePaths?: string[];
additionalThemePaths?: string[];
extensionFactories?: ExtensionFactory[];
noExtensions?: boolean;
noSkills?: boolean;
noPromptTemplates?: boolean;
noThemes?: boolean;
systemPrompt?: string;
appendSystemPrompt?: string;
extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
prompts: PromptTemplate[];
diagnostics: ResourceDiagnostic[];
};
themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
themes: Theme[];
diagnostics: ResourceDiagnostic[];
};
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
systemPromptOverride?: (base: string | undefined) => string | undefined;
appendSystemPromptOverride?: (base: string[]) => string[];
}
export class DefaultResourceLoader implements ResourceLoader {
private cwd: string;
private agentDir: string;
private settingsManager: SettingsManager;
private eventBus: EventBus;
private packageManager: DefaultPackageManager;
private additionalExtensionPaths: string[];
private additionalSkillPaths: string[];
private additionalPromptTemplatePaths: string[];
private additionalThemePaths: string[];
private extensionFactories: ExtensionFactory[];
private noExtensions: boolean;
private noSkills: boolean;
private noPromptTemplates: boolean;
private noThemes: boolean;
private systemPromptSource?: string;
private appendSystemPromptSource?: string;
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
prompts: PromptTemplate[];
diagnostics: ResourceDiagnostic[];
};
private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
themes: Theme[];
diagnostics: ResourceDiagnostic[];
};
private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
private systemPromptOverride?: (base: string | undefined) => string | undefined;
private appendSystemPromptOverride?: (base: string[]) => string[];
private extensionsResult: LoadExtensionsResult;
private skills: Skill[];
private skillDiagnostics: ResourceDiagnostic[];
private prompts: PromptTemplate[];
private promptDiagnostics: ResourceDiagnostic[];
private themes: Theme[];
private themeDiagnostics: ResourceDiagnostic[];
private agentsFiles: Array<{ path: string; content: string }>;
private systemPrompt?: string;
private appendSystemPrompt: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd ?? process.cwd();
this.agentDir = options.agentDir ?? getAgentDir();
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
cwd: this.cwd,
agentDir: this.agentDir,
settingsManager: this.settingsManager,
});
this.additionalExtensionPaths = options.additionalExtensionPaths ?? [];
this.additionalSkillPaths = options.additionalSkillPaths ?? [];
this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];
this.additionalThemePaths = options.additionalThemePaths ?? [];
this.extensionFactories = options.extensionFactories ?? [];
this.noExtensions = options.noExtensions ?? false;
this.noSkills = options.noSkills ?? false;
this.noPromptTemplates = options.noPromptTemplates ?? false;
this.noThemes = options.noThemes ?? false;
this.systemPromptSource = options.systemPrompt;
this.appendSystemPromptSource = options.appendSystemPrompt;
this.extensionsOverride = options.extensionsOverride;
this.skillsOverride = options.skillsOverride;
this.promptsOverride = options.promptsOverride;
this.themesOverride = options.themesOverride;
this.agentsFilesOverride = options.agentsFilesOverride;
this.systemPromptOverride = options.systemPromptOverride;
this.appendSystemPromptOverride = options.appendSystemPromptOverride;
this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
this.skills = [];
this.skillDiagnostics = [];
this.prompts = [];
this.promptDiagnostics = [];
this.themes = [];
this.themeDiagnostics = [];
this.agentsFiles = [];
this.appendSystemPrompt = [];
}
getExtensions(): LoadExtensionsResult {
return this.extensionsResult;
}
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
return { skills: this.skills, diagnostics: this.skillDiagnostics };
}
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
}
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
return { themes: this.themes, diagnostics: this.themeDiagnostics };
}
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {
return { agentsFiles: this.agentsFiles };
}
getSystemPrompt(): string | undefined {
return this.systemPrompt;
}
getAppendSystemPrompt(): string[] {
return this.appendSystemPrompt;
}
async reload(): Promise<void> {
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
});
const extensionPaths = this.noExtensions
? cliExtensionPaths.extensions
: this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions);
let extensionsResult: LoadExtensionsResult;
if (this.noExtensions) {
extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
} else {
extensionsResult = await discoverAndLoadExtensions(extensionPaths, this.cwd, this.agentDir, this.eventBus);
}
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
if (conflicts.length > 0) {
const conflictingPaths = new Set(conflicts.map((c) => c.path));
extensionsResult.extensions = extensionsResult.extensions.filter((ext) => !conflictingPaths.has(ext.path));
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
const skillPaths = this.noSkills
? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths)
: this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths);
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
if (this.noSkills && skillPaths.length === 0) {
skillsResult = { skills: [], diagnostics: [] };
} else {
const result = loadSkills({
cwd: this.cwd,
agentDir: this.agentDir,
skillPaths,
});
skillsResult = { skills: result.skills, diagnostics: this.toDiagnostics(result.warnings) };
}
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
this.skills = resolvedSkills.skills;
this.skillDiagnostics = resolvedSkills.diagnostics;
const promptPaths = this.noPromptTemplates
? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths)
: this.mergePaths(
[...resolvedPaths.prompts, ...cliExtensionPaths.prompts],
this.additionalPromptTemplatePaths,
);
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
if (this.noPromptTemplates && promptPaths.length === 0) {
promptsResult = { prompts: [], diagnostics: [] };
} else {
promptsResult = {
prompts: loadPromptTemplates({
cwd: this.cwd,
agentDir: this.agentDir,
promptPaths,
}),
diagnostics: [],
};
}
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
this.prompts = resolvedPrompts.prompts;
this.promptDiagnostics = resolvedPrompts.diagnostics;
const themePaths = this.noThemes
? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths)
: this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths);
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
if (this.noThemes && themePaths.length === 0) {
themesResult = { themes: [], diagnostics: [] };
} else {
themesResult = this.loadThemes(themePaths);
}
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
this.themes = resolvedThemes.themes;
this.themeDiagnostics = resolvedThemes.diagnostics;
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
this.agentsFiles = resolvedAgentsFiles.agentsFiles;
const baseSystemPrompt = resolvePromptInput(
this.systemPromptSource ?? this.discoverSystemPromptFile(),
"system prompt",
);
this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt;
const appendSource = this.appendSystemPromptSource ?? this.discoverAppendSystemPromptFile();
const resolvedAppend = resolvePromptInput(appendSource, "append system prompt");
const baseAppend = resolvedAppend ? [resolvedAppend] : [];
this.appendSystemPrompt = this.appendSystemPromptOverride
? this.appendSystemPromptOverride(baseAppend)
: baseAppend;
}
private mergePaths(primary: string[], additional: string[]): string[] {
const merged: string[] = [];
const seen = new Set<string>();
for (const p of [...primary, ...additional]) {
const resolved = this.resolveResourcePath(p);
if (seen.has(resolved)) continue;
seen.add(resolved);
merged.push(resolved);
}
return merged;
}
private resolveResourcePath(p: string): string {
const trimmed = p.trim();
let expanded = trimmed;
if (trimmed === "~") {
expanded = homedir();
} else if (trimmed.startsWith("~/")) {
expanded = join(homedir(), trimmed.slice(2));
} else if (trimmed.startsWith("~")) {
expanded = join(homedir(), trimmed.slice(1));
}
return resolve(this.cwd, expanded);
}
private loadThemes(paths: string[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
const themes: Theme[] = [];
const diagnostics: ResourceDiagnostic[] = [];
const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")];
for (const dir of defaultDirs) {
this.loadThemesFromDir(dir, themes, diagnostics);
}
for (const p of paths) {
const resolved = resolve(this.cwd, p);
if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue;
}
try {
const stats = statSync(resolved);
if (stats.isDirectory()) {
this.loadThemesFromDir(resolved, themes, diagnostics);
} else if (stats.isFile() && resolved.endsWith(".json")) {
this.loadThemeFromFile(resolved, themes, diagnostics);
} else {
diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved });
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read theme path";
diagnostics.push({ type: "warning", message, path: resolved });
}
}
return { themes, diagnostics };
}
private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
if (!existsSync(dir)) {
return;
}
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(join(dir, entry.name)).isFile();
} catch {
continue;
}
}
if (!isFile) {
continue;
}
if (!entry.name.endsWith(".json")) {
continue;
}
this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics);
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read theme directory";
diagnostics.push({ type: "warning", message, path: dir });
}
}
private loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
try {
themes.push(loadThemeFromPath(filePath));
} catch (error) {
const message = error instanceof Error ? error.message : "failed to load theme";
diagnostics.push({ type: "warning", message, path: filePath });
}
}
private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{
extensions: Extension[];
errors: Array<{ path: string; error: string }>;
}> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (const [index, factory] of this.extensionFactories.entries()) {
const extensionPath = `<inline:${index + 1}>`;
try {
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
extensions.push(extension);
} catch (error) {
const message = error instanceof Error ? error.message : "failed to load extension";
errors.push({ path: extensionPath, error: message });
}
}
return { extensions, errors };
}
private toDiagnostics(warnings: SkillWarning[]): ResourceDiagnostic[] {
return warnings.map((warning) => ({
type: "warning",
message: warning.message,
path: warning.skillPath,
}));
}
private discoverSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
private discoverAppendSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
const conflicts: Array<{ path: string; message: string }> = [];
// Track which extension registered each tool, command, and flag
const toolOwners = new Map<string, string>();
const commandOwners = new Map<string, string>();
const flagOwners = new Map<string, string>();
for (const ext of extensions) {
// Check tools
for (const toolName of ext.tools.keys()) {
const existingOwner = toolOwners.get(toolName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Tool "${toolName}" conflicts with ${existingOwner}`,
});
} else {
toolOwners.set(toolName, ext.path);
}
}
// Check commands
for (const commandName of ext.commands.keys()) {
const existingOwner = commandOwners.get(commandName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Command "/${commandName}" conflicts with ${existingOwner}`,
});
} else {
commandOwners.set(commandName, ext.path);
}
}
// Check flags
for (const flagName of ext.flags.keys()) {
const existingOwner = flagOwners.get(flagName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Flag "--${flagName}" conflicts with ${existingOwner}`,
});
} else {
flagOwners.set(flagName, ext.path);
}
}
}
return conflicts;
}
}

View file

@ -1,59 +1,21 @@
/**
* SDK for programmatic usage of AgentSession.
*
* Provides a factory function and discovery helpers that allow full control
* over agent configuration, or sensible defaults that match CLI behavior.
*
* @example
* ```typescript
* // Minimal - everything auto-discovered
* const session = await createAgentSession();
*
* // Full control
* const session = await createAgentSession({
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* tools: [readTool, bashTool],
* skills: [],
* sessionFile: false,
* });
* ```
*/
import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Message, Model } from "@mariozechner/pi-ai";
import { join } from "path";
import { getAgentDir, getAuthPath } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
type ExtensionFactory,
ExtensionRunner,
type LoadExtensionsResult,
loadExtensionFromFactory,
type ToolDefinition,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index.js";
import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js";
import type { ResourceLoader } from "./resource-loader.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { SessionManager } from "./session-manager.js";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills.js";
import {
buildSystemPrompt as buildSystemPromptInternal,
loadProjectContextFiles as loadContextFilesInternal,
} from "./system-prompt.js";
import { SettingsManager } from "./settings-manager.js";
import { time } from "./timings.js";
import {
allTools,
bashTool,
codingTools,
createAllTools,
createBashTool,
createCodingTools,
createEditTool,
@ -74,17 +36,15 @@ import {
writeTool,
} from "./tools/index.js";
// Types
export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string;
/** Global config directory. Default: ~/.pi/agent */
agentDir?: string;
/** Auth storage for credentials. Default: discoverAuthStorage(agentDir) */
/** Auth storage for credentials. Default: new AuthStorage(agentDir/auth.json) */
authStorage?: AuthStorage;
/** Model registry. Default: discoverModels(authStorage, agentDir) */
/** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */
modelRegistry?: ModelRegistry;
/** Model to use. Default: from settings, else first available */
@ -94,32 +54,13 @@ export interface CreateAgentSessionOptions {
/** Models available for cycling (Ctrl+P in interactive mode) */
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools to register (in addition to built-in tools). */
customTools?: ToolDefinition[];
/** Inline extensions. When provided (even if empty), skips file discovery. */
extensions?: ExtensionFactory[];
/** Additional extension paths to load (merged with discovery). */
additionalExtensionPaths?: string[];
/**
* Pre-loaded extensions result (skips file discovery).
* @internal Used by CLI when extensions are loaded early to parse custom flags.
*/
preloadedExtensions?: LoadExtensionsResult;
/** Shared event bus for tool/extension communication. Default: creates new bus. */
eventBus?: EventBus;
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
contextFiles?: Array<{ path: string; content: string }>;
/** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */
promptTemplates?: PromptTemplate[];
/** Resource loader. When omitted, DefaultResourceLoader is used. */
resourceLoader?: ResourceLoader;
/** Session manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager;
@ -148,7 +89,6 @@ export type {
ToolDefinition,
} from "./extensions/index.js";
export type { PromptTemplate } from "./prompt-templates.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { Tool } from "./tools/index.js";
@ -182,133 +122,6 @@ function getDefaultAgentDir(): string {
return getAgentDir();
}
// Discovery Functions
/**
* Create an AuthStorage instance for the given agent directory.
*/
export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage {
return new AuthStorage(join(agentDir, "auth.json"));
}
/**
* Create a ModelRegistry for the given agent directory.
*/
export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry {
return new ModelRegistry(authStorage, join(agentDir, "models.json"));
}
/**
* Discover extensions from cwd and agentDir.
* @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too.
* @param cwd - Current working directory
* @param agentDir - Agent configuration directory
*/
export async function discoverExtensions(
eventBus: EventBus,
cwd?: string,
agentDir?: string,
): Promise<LoadExtensionsResult> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus);
// Log errors but don't fail
for (const { path, error } of result.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
return result;
}
/**
* Discover skills from cwd and agentDir.
*/
export function discoverSkills(
cwd?: string,
agentDir?: string,
settings?: SkillsSettings,
): { skills: Skill[]; warnings: SkillWarning[] } {
return loadSkillsInternal({
...settings,
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
/**
* Discover context files (AGENTS.md) walking up from cwd.
*/
export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> {
return loadContextFilesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
/**
* Discover prompt templates from cwd and agentDir.
*/
export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] {
return loadPromptTemplatesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
// API Key Helpers
// System Prompt
export interface BuildSystemPromptOptions {
tools?: Tool[];
skills?: Skill[];
contextFiles?: Array<{ path: string; content: string }>;
cwd?: string;
appendPrompt?: string;
}
/**
* Build the default system prompt.
*/
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
return buildSystemPromptInternal({
cwd: options.cwd,
skills: options.skills,
contextFiles: options.contextFiles,
appendSystemPrompt: options.appendPrompt,
});
}
// Settings
/**
* Load settings from agentDir/settings.json merged with cwd/.pi/settings.json.
*/
export function loadSettings(cwd?: string, agentDir?: string): Settings {
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
return {
defaultProvider: manager.getDefaultProvider(),
defaultModel: manager.getDefaultModel(),
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
steeringMode: manager.getSteeringMode(),
followUpMode: manager.getFollowUpMode(),
theme: manager.getTheme(),
compaction: manager.getCompactionSettings(),
retry: manager.getRetrySettings(),
hideThinkingBlock: manager.getHideThinkingBlock(),
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
extensions: manager.getExtensionPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
};
}
// Factory
/**
* Create an AgentSession with the specified options.
*
@ -330,12 +143,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
* });
*
* // Full control
* const loader = new DefaultResourceLoader({
* cwd: process.cwd(),
* agentDir: getAgentDir(),
* settingsManager: SettingsManager.create(),
* });
* await loader.reload();
* const { session } = await createAgentSession({
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* systemPrompt: 'You are helpful.',
* tools: [readTool, bashTool],
* skills: [],
* resourceLoader: loader,
* sessionManager: SessionManager.inMemory(),
* });
* ```
@ -343,21 +160,25 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
const eventBus = options.eventBus ?? createEventBus();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry
const authStorage = options.authStorage ?? discoverAuthStorage(agentDir);
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir);
time("discoverModels");
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const authStorage = options.authStorage ?? new AuthStorage(authPath);
const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath);
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
time("settingsManager");
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
time("sessionManager");
if (!resourceLoader) {
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();
time("resourceLoader.reload");
}
// Check if session has existing data to restore
const existingSession = sessionManager.buildSessionContext();
time("loadSession");
const hasExistingSession = existingSession.messages.length > 0;
let model = options.model;
@ -394,7 +215,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
break;
}
}
time("findAvailableModel");
if (model) {
if (modelFallbackMessage) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
@ -422,161 +242,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel = "off";
}
let skills: Skill[];
let skillWarnings: SkillWarning[];
if (options.skills !== undefined) {
skills = options.skills;
skillWarnings = [];
} else {
const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
skills = discovered.skills;
skillWarnings = discovered.warnings;
}
time("discoverSkills");
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
time("discoverContextFiles");
const autoResizeImages = settingsManager.getImageAutoResize();
const shellCommandPrefix = settingsManager.getShellCommandPrefix();
// Create ALL built-in tools for the registry (extensions can enable any of them)
const allBuiltInToolsMap = createAllTools(cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
// Determine initially active built-in tools (default: read, bash, edit, write)
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
const initialActiveToolNames: ToolName[] = options.tools
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap)
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allTools)
: defaultActiveToolNames;
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
time("createAllTools");
// Load extensions (discovers from standard locations + configured paths)
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions !== undefined) {
// Use pre-loaded extensions (from early CLI flag discovery)
extensionsResult = options.preloadedExtensions;
} else if (options.extensions !== undefined) {
// User explicitly provided extensions array (even if empty) - skip discovery
// Create runtime for inline extensions
const runtime = createExtensionRuntime();
extensionsResult = {
extensions: [],
errors: [],
runtime,
};
} else {
// Discover extensions, merging with additional paths
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];
extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus);
time("discoverAndLoadExtensions");
for (const { path, error } of extensionsResult.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
}
// Load inline extensions from factories
if (options.extensions && options.extensions.length > 0) {
for (let i = 0; i < options.extensions.length; i++) {
const factory = options.extensions[i];
const loaded = await loadExtensionFromFactory(
factory,
cwd,
eventBus,
extensionsResult.runtime,
`<inline-${i}>`,
);
extensionsResult.extensions.push(loaded);
}
}
// Create extension runner if we have extensions or SDK custom tools
// The runner provides consistent context for tool execution (shutdown, abort, etc.)
let extensionRunner: ExtensionRunner | undefined;
const hasExtensions = extensionsResult.extensions.length > 0;
const hasCustomTools = options.customTools && options.customTools.length > 0;
if (hasExtensions || hasCustomTools) {
extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
cwd,
sessionManager,
modelRegistry,
);
}
// Wrap extension-registered tools and SDK-provided custom tools
// Tools use runner.createContext() for consistent context with event handlers
let agent: Agent;
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
// Combine extension-registered tools with SDK-provided custom tools
const allCustomTools = [
...registeredTools,
...(options.customTools?.map((def) => ({ definition: def, extensionPath: "<sdk>" })) ?? []),
];
// Wrap tools using runner's context (ensures shutdown, abort, etc. work correctly)
const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : [];
// Create tool registry mapping name -> tool (for extension getTools/setTools)
// Registry contains ALL built-in tools so extensions can enable any of them
const toolRegistry = new Map<string, AgentTool>();
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
toolRegistry.set(name, tool as AgentTool);
}
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
// Initially active tools = active built-in + extension tools
// Extension tools can override built-in tools with the same name
const extensionToolNames = new Set(wrappedExtensionTools.map((t) => t.name));
const nonOverriddenBuiltInTools = initialActiveBuiltInTools.filter((t) => !extensionToolNames.has(t.name));
let activeToolsArray: Tool[] = [...nonOverriddenBuiltInTools, ...wrappedExtensionTools];
time("combineTools");
// Wrap tools with extensions if available
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
if (extensionRunner) {
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
// Wrap ALL registry tools (not just active) so extensions can enable any
const allRegistryTools = Array.from(toolRegistry.values());
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
wrappedToolRegistry = new Map<string, AgentTool>();
for (const tool of wrappedAllTools) {
wrappedToolRegistry.set(tool.name, tool);
}
}
// Function to rebuild system prompt when tools change
// Captures static options (cwd, agentDir, skills, contextFiles, customPrompt)
const rebuildSystemPrompt = (toolNames: string[]): string => {
// Filter to valid tool names
const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap);
const defaultPrompt = buildSystemPromptInternal({
cwd,
agentDir,
skills,
contextFiles,
selectedTools: validToolNames,
});
if (options.systemPrompt === undefined) {
return defaultPrompt;
} else if (typeof options.systemPrompt === "string") {
// String is a full replacement - use as-is without appending context/skills
return options.systemPrompt;
} else {
return options.systemPrompt(defaultPrompt);
}
};
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
time("buildSystemPrompt");
const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir);
time("discoverPromptTemplates");
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
@ -615,20 +286,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
};
const extensionRunnerRef: { current?: ExtensionRunner } = {};
agent = new Agent({
initialState: {
systemPrompt,
systemPrompt: "",
model,
thinkingLevel,
tools: activeToolsArray,
tools: [],
},
convertToLlm: convertToLlmWithBlockImages,
sessionId: sessionManager.getSessionId(),
transformContext: extensionRunner
? async (messages) => {
return extensionRunner.emitContext(messages);
}
: undefined,
transformContext: async (messages) => {
const runner = extensionRunnerRef.current;
if (!runner) return messages;
return runner.emitContext(messages);
},
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
thinkingBudgets: settingsManager.getThinkingBudgets(),
@ -658,7 +331,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return key;
},
});
time("createAgent");
// Restore messages if session has existing data
if (hasExistingSession) {
@ -675,17 +347,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
agent,
sessionManager,
settingsManager,
cwd,
scopedModels: options.scopedModels,
promptTemplates: promptTemplates,
extensionRunner,
skills,
skillWarnings,
skillsSettings: settingsManager.getSkillsSettings(),
resourceLoader,
customTools: options.customTools,
modelRegistry,
toolRegistry: wrappedToolRegistry ?? toolRegistry,
rebuildSystemPrompt,
initialActiveToolNames,
extensionRunnerRef,
});
time("createAgentSession");
const extensionsResult = resourceLoader.getExtensions();
return {
session,

View file

@ -18,19 +18,6 @@ export interface RetrySettings {
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
}
export interface SkillsSettings {
enabled?: boolean; // default: true
enableCodexUser?: boolean; // default: true
enableClaudeUser?: boolean; // default: true
enableClaudeProject?: boolean; // default: true
enablePiUser?: boolean; // default: true
enablePiProject?: boolean; // default: true
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
customDirectories?: string[]; // default: []
ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
}
export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
}
@ -67,8 +54,11 @@ export interface Settings {
quietStartup?: boolean;
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
extensions?: string[]; // Array of extension file paths
skills?: SkillsSettings;
extensions?: string[]; // Array of extension file paths or directories
skills?: string[]; // Array of skill file paths or directories
prompts?: string[]; // Array of prompt template paths or directories
themes?: string[]; // Array of theme file paths or directories
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
terminal?: TerminalSettings;
images?: ImageSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
@ -165,6 +155,27 @@ export class SettingsManager {
settings.steeringMode = settings.queueMode;
delete settings.queueMode;
}
if (
"skills" in settings &&
typeof settings.skills === "object" &&
settings.skills !== null &&
!Array.isArray(settings.skills)
) {
const skillsSettings = settings.skills as {
enableSkillCommands?: boolean;
customDirectories?: unknown;
};
if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) {
settings.enableSkillCommands = skillsSettings.enableSkillCommands;
}
if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) {
settings.skills = skillsSettings.customDirectories;
} else {
delete settings.skills;
}
}
return settings as Settings;
}
@ -183,6 +194,14 @@ export class SettingsManager {
}
}
getGlobalSettings(): Settings {
return structuredClone(this.globalSettings);
}
getProjectSettings(): Settings {
return this.loadProjectSettings();
}
/** Apply additional overrides on top of current settings */
applyOverrides(overrides: Partial<Settings>): void {
this.settings = deepMergeSettings(this.settings, overrides);
@ -214,6 +233,21 @@ export class SettingsManager {
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
}
private saveProjectSettings(settings: Settings): void {
if (!this.persist || !this.projectSettingsPath) {
return;
}
try {
const dir = dirname(this.projectSettingsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(this.projectSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
} catch (error) {
console.error(`Warning: Could not save project settings file: ${error}`);
}
}
getLastChangelogVersion(): string | undefined {
return this.settings.lastChangelogVersion;
}
@ -391,42 +425,46 @@ export class SettingsManager {
this.save();
}
getSkillsEnabled(): boolean {
return this.settings.skills?.enabled ?? true;
setProjectExtensionPaths(paths: string[]): void {
const projectSettings = this.loadProjectSettings();
projectSettings.extensions = paths;
this.saveProjectSettings(projectSettings);
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
}
setSkillsEnabled(enabled: boolean): void {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enabled = enabled;
getSkillPaths(): string[] {
return [...(this.settings.skills ?? [])];
}
setSkillPaths(paths: string[]): void {
this.globalSettings.skills = paths;
this.save();
}
getSkillsSettings(): Required<SkillsSettings> {
return {
enabled: this.settings.skills?.enabled ?? true,
enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
enablePiUser: this.settings.skills?.enablePiUser ?? true,
enablePiProject: this.settings.skills?.enablePiProject ?? true,
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
};
getPromptTemplatePaths(): string[] {
return [...(this.settings.prompts ?? [])];
}
setPromptTemplatePaths(paths: string[]): void {
this.globalSettings.prompts = paths;
this.save();
}
getThemePaths(): string[] {
return [...(this.settings.themes ?? [])];
}
setThemePaths(paths: string[]): void {
this.globalSettings.themes = paths;
this.save();
}
getEnableSkillCommands(): boolean {
return this.settings.skills?.enableSkillCommands ?? true;
return this.settings.enableSkillCommands ?? true;
}
setEnableSkillCommands(enabled: boolean): void {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enableSkillCommands = enabled;
this.globalSettings.enableSkillCommands = enabled;
this.save();
}

View file

@ -1,10 +1,8 @@
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
import { minimatch } from "minimatch";
import { homedir } from "os";
import { basename, dirname, join, resolve } from "path";
import { basename, dirname, isAbsolute, join, resolve } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import type { SkillsSettings } from "./settings-manager.js";
/**
* Standard frontmatter fields per Agent Skills spec.
@ -49,8 +47,6 @@ export interface LoadSkillsResult {
warnings: SkillWarning[];
}
type SkillFormat = "recursive" | "claude";
/**
* Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid).
@ -88,7 +84,7 @@ function validateDescription(description: string | undefined): string[] {
const errors: string[] = [];
if (!description || description.trim() === "") {
errors.push(`description is required`);
errors.push("description is required");
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
}
@ -117,15 +113,18 @@ export interface LoadSkillsFromDirOptions {
}
/**
* Load skills from a directory recursively.
* Skills are directories containing a SKILL.md file with frontmatter including a description.
* Load skills from a directory.
*
* Discovery rules:
* - direct .md children in the root
* - recursive SKILL.md under subdirectories
*/
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
const { dir, source } = options;
return loadSkillsFromDirInternal(dir, source, "recursive");
return loadSkillsFromDirInternal(dir, source, true);
}
function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult {
function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles: boolean): LoadSkillsResult {
const skills: Skill[] = [];
const warnings: SkillWarning[] = [];
@ -162,36 +161,28 @@ function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFor
}
}
if (format === "recursive") {
// Recursive format: scan directories, look for SKILL.md files
if (isDirectory) {
const subResult = loadSkillsFromDirInternal(fullPath, source, format);
skills.push(...subResult.skills);
warnings.push(...subResult.warnings);
} else if (isFile && entry.name === "SKILL.md") {
const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
}
} else if (format === "claude") {
// Claude format: only one level deep, each directory must contain SKILL.md
if (!isDirectory) {
continue;
}
const skillFile = join(fullPath, "SKILL.md");
if (!existsSync(skillFile)) {
continue;
}
const result = loadSkillFromFile(skillFile, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
if (isDirectory) {
const subResult = loadSkillsFromDirInternal(fullPath, source, false);
skills.push(...subResult.skills);
warnings.push(...subResult.warnings);
continue;
}
if (!isFile) {
continue;
}
const isRootMd = includeRootFiles && entry.name.endsWith(".md");
const isSkillMd = !includeRootFiles && entry.name === "SKILL.md";
if (!isRootMd && !isSkillMd) {
continue;
}
const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
}
} catch {}
@ -290,11 +281,26 @@ function escapeXml(str: string): string {
.replace(/'/g, "&apos;");
}
export interface LoadSkillsOptions extends SkillsSettings {
export interface LoadSkillsOptions {
/** Working directory for project-local skills. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global skills. Default: ~/.pi/agent */
agentDir?: string;
/** Explicit skill paths (files or directories) */
skillPaths?: string[];
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveSkillPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
@ -302,18 +308,7 @@ export interface LoadSkillsOptions extends SkillsSettings {
* Returns skills and any validation warnings.
*/
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const {
cwd = process.cwd(),
agentDir,
enableCodexUser = true,
enableClaudeUser = true,
enableClaudeProject = true,
enablePiUser = true,
enablePiProject = true,
customDirectories = [],
ignoredSkills = [],
includeSkills = [],
} = options;
const { cwd = process.cwd(), agentDir, skillPaths = [] } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
@ -323,30 +318,9 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const allWarnings: SkillWarning[] = [];
const collisionWarnings: SkillWarning[] = [];
// Check if skill name matches any of the include patterns
function matchesIncludePatterns(name: string): boolean {
if (includeSkills.length === 0) return true; // No filter = include all
return includeSkills.some((pattern) => minimatch(name, pattern));
}
// Check if skill name matches any of the ignore patterns
function matchesIgnorePatterns(name: string): boolean {
if (ignoredSkills.length === 0) return false;
return ignoredSkills.some((pattern) => minimatch(name, pattern));
}
function addSkills(result: LoadSkillsResult) {
allWarnings.push(...result.warnings);
for (const skill of result.skills) {
// Apply ignore filter (glob patterns) - takes precedence over include
if (matchesIgnorePatterns(skill.name)) {
continue;
}
// Apply include filter (glob patterns)
if (!matchesIncludePatterns(skill.name)) {
continue;
}
// Resolve symlinks to detect duplicate files
let realPath: string;
try {
@ -373,23 +347,34 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
}
}
if (enableCodexUser) {
addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive"));
}
if (enableClaudeUser) {
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
}
if (enableClaudeProject) {
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
}
if (enablePiUser) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
}
if (enablePiProject) {
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive"));
}
for (const customDir of customDirectories) {
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd);
if (!existsSync(resolvedPath)) {
allWarnings.push({ skillPath: resolvedPath, message: "skill path does not exist" });
continue;
}
try {
const stats = statSync(resolvedPath);
if (stats.isDirectory()) {
addSkills(loadSkillsFromDirInternal(resolvedPath, "custom", true));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const result = loadSkillFromFile(resolvedPath, "custom");
if (result.skill) {
addSkills({ skills: [result.skill], warnings: result.warnings });
} else {
allWarnings.push(...result.warnings);
}
} else {
allWarnings.push({ skillPath: resolvedPath, message: "skill path is not a markdown file" });
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read skill path";
allWarnings.push({ skillPath: resolvedPath, message });
}
}
return {

View file

@ -2,16 +2,11 @@
* System prompt construction and project context loading
*/
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
import type { ToolName } from "./tools/index.js";
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import { formatSkillsForPrompt, type Skill } from "./skills.js";
/** Tool descriptions for system prompt */
const toolDescriptions: Record<ToolName, string> = {
const toolDescriptions: Record<string, string> = {
read: "Read file contents",
bash: "Execute bash commands (ls, grep, find, etc.)",
edit: "Make surgical edits to files (find exact text and replace)",
@ -21,117 +16,18 @@ const toolDescriptions: Record<ToolName, string> = {
ls: "List directory contents",
};
/** Resolve input as file path or literal string */
export function resolvePromptInput(input: string | undefined, description: string): string | undefined {
if (!input) {
return undefined;
}
if (existsSync(input)) {
try {
return readFileSync(input, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
return input;
}
/** Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
const candidates = ["AGENTS.md", "CLAUDE.md"];
for (const filename of candidates) {
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
};
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
}
}
}
return null;
}
export interface LoadContextFilesOptions {
/** Working directory to start walking up from. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global context. Default: from getAgentDir() */
agentDir?: string;
}
/**
* Load all project context files in order:
* 1. Global: agentDir/AGENTS.md or CLAUDE.md
* 2. Parent directories (top-most first) down to cwd
* Each returns {path, content} for separate messages
*/
export function loadProjectContextFiles(
options: LoadContextFilesOptions = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getAgentDir();
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
// 1. Load global context from agentDir
const globalContext = loadContextFileFromDir(resolvedAgentDir);
if (globalContext) {
contextFiles.push(globalContext);
seenPaths.add(globalContext.path);
}
// 2. Walk up from cwd to root, collecting all context files
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
// Add to beginning so we get top-most parent first
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
// Stop if we've reached root
if (currentDir === root) break;
// Move up one directory
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break; // Safety check
currentDir = parentDir;
}
// Add ancestor files in order (top-most → cwd)
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
customPrompt?: string;
/** Tools to include in prompt. Default: [read, bash, edit, write] */
selectedTools?: ToolName[];
selectedTools?: string[];
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Skills settings for discovery. */
skillsSettings?: SkillsSettings;
/** Working directory. Default: process.cwd() */
cwd?: string;
/** Agent config directory. Default: from getAgentDir() */
agentDir?: string;
/** Pre-loaded context files (skips discovery if provided). */
/** Pre-loaded context files. */
contextFiles?: Array<{ path: string; content: string }>;
/** Pre-loaded skills (skips discovery if provided). */
/** Pre-loaded skills. */
skills?: Skill[];
}
@ -141,15 +37,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
customPrompt,
selectedTools,
appendSystemPrompt,
skillsSettings,
cwd,
agentDir,
contextFiles: providedContextFiles,
skills: providedSkills,
} = options;
const resolvedCwd = cwd ?? process.cwd();
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
@ -163,18 +55,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
timeZoneName: "short",
});
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
// Resolve context files: use provided or discover
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir });
const contextFiles = providedContextFiles ?? [];
const skills = providedSkills ?? [];
// Resolve skills: use provided or discover
const skills =
providedSkills ??
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []);
if (resolvedCustomPrompt) {
let prompt = resolvedCustomPrompt;
if (customPrompt) {
let prompt = customPrompt;
if (appendSection) {
prompt += appendSection;
@ -208,8 +95,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const examplesPath = getExamplesPath();
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n") : "(none)";
const tools = selectedTools || ["read", "bash", "edit", "write"];
const toolsList =
tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t] ?? "Custom tool"}`).join("\n") : "(none)";
// Build guidelines based on which tools are actually available
const guidelinesList: string[] = [];
@ -222,16 +110,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const hasLs = tools.includes("ls");
const hasRead = tools.includes("read");
// Bash without edit/write = read-only bash mode
if (hasBash && !hasEdit && !hasWrite) {
guidelinesList.push(
"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
);
}
// File exploration guidelines
if (hasBash && !hasGrep && !hasFind && !hasLs) {
guidelinesList.push("Use bash for file operations like ls, grep, find");
guidelinesList.push("Use bash for file operations like ls, rg, find");
} else if (hasBash && (hasGrep || hasFind || hasLs)) {
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
}
@ -251,7 +132,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
guidelinesList.push("Use write only for new files or complete rewrites");
}
// Output guideline (only when actually writing/executing)
// Output guideline (only when actually writing or executing)
if (hasEdit || hasWrite) {
guidelinesList.push(
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
@ -274,7 +155,7 @@ In addition to the tools above, you may have access to other custom tools depend
Guidelines:
${guidelines}
Pi documentation (only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)

View file

@ -5,7 +5,7 @@ import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig, killProcessTree } from "../../utils/shell.js";
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
@ -65,6 +65,7 @@ const defaultBashOperations: BashOperations = {
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});

View file

@ -98,6 +98,7 @@ export type {
WidgetPlacement,
} from "./core/extensions/index.js";
export {
createExtensionRuntime,
ExtensionRunner,
isBashToolResult,
isEditToolResult,
@ -115,10 +116,12 @@ export {
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
export type { PackageManager, ProgressCallback, ProgressEvent } from "./core/package-manager.js";
export { DefaultPackageManager } from "./core/package-manager.js";
export type { ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage
export {
type BuildSystemPromptOptions,
buildSystemPrompt,
type CreateAgentSessionOptions,
type CreateAgentSessionResult,
// Factory
@ -133,14 +136,6 @@ export {
createReadOnlyTools,
createReadTool,
createWriteTool,
// Discovery
discoverAuthStorage,
discoverContextFiles,
discoverExtensions,
discoverModels,
discoverPromptTemplates,
discoverSkills,
loadSettings,
type PromptTemplate,
// Pre-built tools (use process.cwd())
readOnlyTools,
@ -172,9 +167,7 @@ export {
type CompactionSettings,
type ImageSettings,
type RetrySettings,
type Settings,
SettingsManager,
type SkillsSettings,
} from "./core/settings-manager.js";
// Skills
export {

View file

@ -7,24 +7,23 @@
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
import { createInterface } from "readline";
import { type Args, parseArgs, printHelp } from "./cli/args.js";
import { processFileArguments } from "./cli/file-processor.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import { createEventBus } from "./core/event-bus.js";
import { getAgentDir, getModelsPath, VERSION } from "./config.js";
import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadExtensions, type LoadExtensionsResult, loadExtensions } from "./core/extensions/index.js";
import type { LoadExtensionsResult } from "./core/extensions/index.js";
import { KeybindingsManager } from "./core/keybindings.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
import { DefaultPackageManager } from "./core/package-manager.js";
import { DefaultResourceLoader } from "./core/resource-loader.js";
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { resolvePromptInput } from "./core/system-prompt.js";
import { printTimings, time } from "./core/timings.js";
import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
@ -54,6 +53,156 @@ async function readPipedStdin(): Promise<string | undefined> {
});
}
type PackageCommand = "install" | "remove" | "update" | "list";
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
local: boolean;
}
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
const [command, ...rest] = args;
if (command !== "install" && command !== "remove" && command !== "update" && command !== "list") {
return undefined;
}
let local = false;
const sources: string[] = [];
for (const arg of rest) {
if (arg === "-l" || arg === "--local") {
local = true;
continue;
}
sources.push(arg);
}
return { command, source: sources[0], local };
}
function normalizeExtensionSource(source: string): { type: "npm" | "git" | "local"; key: string } {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/);
return { type: "npm", key: match?.[1] ?? spec };
}
if (source.startsWith("git:")) {
const repo = source.slice("git:".length).trim().split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "") };
}
return { type: "local", key: source };
}
function sourcesMatch(a: string, b: string): boolean {
const left = normalizeExtensionSource(a);
const right = normalizeExtensionSource(b);
return left.type === right.type && left.key === right.key;
}
function updateExtensionSources(
settingsManager: SettingsManager,
source: string,
local: boolean,
action: "add" | "remove",
): void {
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const currentSources = currentSettings.extensions ?? [];
let nextSources: string[];
if (action === "add") {
const exists = currentSources.some((existing) => sourcesMatch(existing, source));
nextSources = exists ? currentSources : [...currentSources, source];
} else {
nextSources = currentSources.filter((existing) => !sourcesMatch(existing, source));
}
if (local) {
settingsManager.setProjectExtensionPaths(nextSources);
} else {
settingsManager.setExtensionPaths(nextSources);
}
}
async function handlePackageCommand(args: string[]): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
// Set up progress callback for CLI feedback
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
} else if (event.type === "error") {
console.error(chalk.red(`Error: ${event.message}`));
}
});
if (options.command === "install") {
if (!options.source) {
console.error(chalk.red("Missing install source."));
process.exit(1);
}
await packageManager.install(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "add");
console.log(chalk.green(`Installed ${options.source}`));
return true;
}
if (options.command === "remove") {
if (!options.source) {
console.error(chalk.red("Missing remove source."));
process.exit(1);
}
await packageManager.remove(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "remove");
console.log(chalk.green(`Removed ${options.source}`));
return true;
}
if (options.command === "list") {
const globalSettings = settingsManager.getGlobalSettings();
const projectSettings = settingsManager.getProjectSettings();
const globalExtensions = globalSettings.extensions ?? [];
const projectExtensions = projectSettings.extensions ?? [];
if (globalExtensions.length === 0 && projectExtensions.length === 0) {
console.log(chalk.dim("No extensions installed."));
return true;
}
if (globalExtensions.length > 0) {
console.log(chalk.bold("Global extensions:"));
for (const ext of globalExtensions) {
console.log(` ${ext}`);
}
}
if (projectExtensions.length > 0) {
if (globalExtensions.length > 0) console.log();
console.log(chalk.bold("Project extensions:"));
for (const ext of projectExtensions) {
console.log(` ${ext}`);
}
}
return true;
}
await packageManager.update(options.source);
if (options.source) {
console.log(chalk.green(`Updated ${options.source}`));
} else {
console.log(chalk.green("Updated extensions"));
}
return true;
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
@ -173,58 +322,15 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
return undefined;
}
/** Discover SYSTEM.md file if no CLI system prompt was provided */
function discoverSystemPromptFile(): string | undefined {
// Check project-local first: .pi/SYSTEM.md
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
// Fall back to global: ~/.pi/agent/SYSTEM.md
const globalPath = join(getAgentDir(), "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
/** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
function discoverAppendSystemPromptFile(): string | undefined {
// Check project-local first: .pi/APPEND_SYSTEM.md
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
// Fall back to global: ~/.pi/agent/APPEND_SYSTEM.md
const globalPath = join(getAgentDir(), "APPEND_SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
settingsManager: SettingsManager,
extensionsResult?: LoadExtensionsResult,
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
// Auto-discover SYSTEM.md if no CLI system prompt provided
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
// Auto-discover APPEND_SYSTEM.md if no CLI append system prompt provided
const appendSystemPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPromptSource, "append system prompt");
if (sessionManager) {
options.sessionManager = sessionManager;
}
@ -276,15 +382,6 @@ function buildSessionOptions(
// API key from CLI - set in authStorage
// (handled by caller before createAgentSession)
// System prompt
if (resolvedSystemPrompt && resolvedAppendPrompt) {
options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
} else if (resolvedSystemPrompt) {
options.systemPrompt = resolvedSystemPrompt;
} else if (resolvedAppendPrompt) {
options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
}
// Tools
if (parsed.noTools) {
// --no-tools: start with no built-in tools
@ -298,60 +395,52 @@ function buildSessionOptions(
options.tools = parsed.tools.map((name) => allTools[name]);
}
// Skills
if (parsed.noSkills) {
options.skills = [];
}
// Pre-loaded extensions (from early CLI flag discovery)
if (extensionsResult) {
options.preloadedExtensions = extensionsResult;
}
return options;
}
export async function main(args: string[]) {
time("start");
if (await handlePackageCommand(args)) {
return;
}
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
// Create AuthStorage and ModelRegistry upfront
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
time("discoverModels");
const authStorage = new AuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
// First pass: parse args to get --extension paths
const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load extensions to discover their CLI flags (unless --no-extensions)
// Early load extensions to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const eventBus = createEventBus();
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
const settingsManager = SettingsManager.create(cwd, agentDir);
let extensionsResult: LoadExtensionsResult;
if (firstPass.noExtensions) {
// --no-extensions disables discovery, but explicit -e flags still work
const explicitPaths = firstPass.extensions ?? [];
extensionsResult = await loadExtensions(explicitPaths, cwd, eventBus);
time("loadExtensions");
} else {
// Merge CLI --extension args with settings.json extensions
const extensionPaths = [...settingsManager.getExtensionPaths(), ...(firstPass.extensions ?? [])];
extensionsResult = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
time("discoverExtensionFlags");
}
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
additionalExtensionPaths: firstPass.extensions,
additionalSkillPaths: firstPass.skills,
additionalPromptTemplatePaths: firstPass.promptTemplates,
additionalThemePaths: firstPass.themes,
noExtensions: firstPass.noExtensions,
noSkills: firstPass.noSkills,
noPromptTemplates: firstPass.noPromptTemplates,
noThemes: firstPass.noThemes,
systemPrompt: firstPass.systemPrompt,
appendSystemPrompt: firstPass.appendSystemPrompt,
});
await resourceLoader.reload();
time("resourceLoader.reload");
// Log extension loading errors
const extensionsResult: LoadExtensionsResult = resourceLoader.getExtensions();
for (const { path, error } of extensionsResult.errors) {
console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
}
// Collect all extension flags
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of extensionsResult.extensions) {
for (const [name, flag] of ext.flags) {
@ -361,7 +450,6 @@ export async function main(args: string[]) {
// Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags);
time("parseArgs");
// Pass flag values to extensions via runtime
for (const [name, value] of parsed.unknownFlags) {
@ -393,7 +481,6 @@ export async function main(args: string[]) {
// Prepend stdin content to messages
parsed.messages.unshift(stdinContent);
}
time("readPipedStdin");
}
if (parsed.export) {
@ -415,11 +502,9 @@ export async function main(args: string[]) {
}
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
initTheme(settingsManager.getTheme(), isInteractive);
time("initTheme");
// Show deprecation warnings in interactive mode
if (isInteractive && deprecationWarnings.length > 0) {
@ -430,12 +515,10 @@ export async function main(args: string[]) {
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
if (modelPatterns && modelPatterns.length > 0) {
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
time("resolveModelScope");
}
// Create session manager based on CLI flags
let sessionManager = await createSessionManager(parsed, cwd);
time("createSessionManager");
// Handle --resume: show session picker
if (parsed.resume) {
@ -446,7 +529,6 @@ export async function main(args: string[]) {
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
SessionManager.listAll,
);
time("selectSession");
if (!selectedPath) {
console.log(chalk.dim("No session selected"));
stopThemeWatcher();
@ -455,17 +537,10 @@ export async function main(args: string[]) {
sessionManager = SessionManager.open(selectedPath);
}
const sessionOptions = buildSessionOptions(
parsed,
scopedModels,
sessionManager,
modelRegistry,
settingsManager,
extensionsResult,
);
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, settingsManager);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.eventBus = eventBus;
sessionOptions.resourceLoader = resourceLoader;
// Handle CLI --api-key as runtime override (not persisted)
if (parsed.apiKey) {
@ -476,9 +551,7 @@ export async function main(args: string[]) {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
}
time("buildSessionOptions");
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) {
console.error(chalk.red("No models available."));

View file

@ -1,42 +1,66 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
private loader: CancellableLoader | Loader;
private cancellable: boolean;
private signalController?: AbortController;
constructor(tui: TUI, theme: Theme, message: string) {
constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
super();
this.cancellable = options?.cancellable ?? true;
const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
if (this.cancellable) {
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
} else {
this.signalController = new AbortController();
this.loader = new Loader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
}
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
if (this.cancellable) {
this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
}
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
}
get signal(): AbortSignal {
return this.loader.signal;
if (this.cancellable) {
return (this.loader as CancellableLoader).signal;
}
return this.signalController?.signal ?? new AbortController().signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
if (this.cancellable) {
(this.loader as CancellableLoader).onAbort = fn;
}
}
handleInput(data: string): void {
this.loader.handleInput(data);
if (this.cancellable) {
(this.loader as CancellableLoader).handleInput(data);
}
}
dispose(): void {
this.loader.dispose();
if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
this.loader.dispose();
}
}
}

View file

@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
@ -156,7 +155,6 @@ export class InteractiveMode {
private keybindings: KeybindingsManager;
private version: string;
private isInitialized = false;
private hasRenderedInitialMessages = false;
private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined;
private readonly defaultWorkingMessage = "Working...";
@ -321,6 +319,7 @@ export class InteractiveMode {
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
];
// Convert prompt templates to SlashCommand format for autocomplete
@ -617,133 +616,89 @@ export class InteractiveMode {
// Extension System
// =========================================================================
private formatDisplayPath(p: string): string {
const home = os.homedir();
let result = p;
// Shorten temp npm paths: /tmp/.../npm/.../node_modules/pkg/... -> npm:pkg/... (temp)
const npmTempMatch = result.match(/pi-extensions\/npm\/[^/]+\/node_modules\/([^/]+)(.*)/);
if (npmTempMatch) {
return `npm:${npmTempMatch[1]}${npmTempMatch[2]} (temp)`;
}
// Shorten temp git paths: /tmp/.../git-host/hash/path/... -> git:host/path/... (temp)
const gitTempMatch = result.match(/pi-extensions\/git-([^/]+)\/[^/]+\/(.*)/);
if (gitTempMatch) {
return `git:${gitTempMatch[1]}/${gitTempMatch[2]} (temp)`;
}
// Replace home directory with ~
if (result.startsWith(home)) {
result = `~${result.slice(home.length)}`;
}
return result;
}
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
if (!shouldShow) {
return;
}
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${this.formatDisplayPath(s.filePath)}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings
.map((w) => theme.fg("warning", ` ${this.formatDisplayPath(w.skillPath)}: ${w.message}`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const extensionPaths = options?.extensionPaths ?? [];
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${this.formatDisplayPath(p)}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
/**
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
// Show discovery info unless silenced
if (!this.settingsManager.getQuietStartup()) {
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded skills (already discovered by SDK)
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show skill warnings if any
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded prompt templates
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) {
return; // No extensions loaded
this.showLoadedResources({ extensionPaths: [], force: false });
return;
}
// Create extension UI context
const uiContext = this.createExtensionUIContext();
extensionRunner.initialize(
// ExtensionActions - for pi.* API
{
sendMessage: (message, options) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendCustomMessage(message, options)
.then(() => {
// Don't rebuild if initial render hasn't happened yet
// (renderInitialMessages will handle it)
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
this.rebuildChatFromMessages();
}
})
.catch((err) => {
this.showError(
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
sendUserMessage: (content, options) => {
this.session.sendUserMessage(content, options).catch((err) => {
this.showError(
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
this.updateTerminalTitle();
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => this.session.getActiveToolNames(),
getAllTools: () => this.session.getAllTools(),
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await this.session.modelRegistry.getApiKey(model);
if (!key) return false;
await this.session.setModel(model);
return true;
},
getThinkingLevel: () => this.session.thinkingLevel,
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
},
// ExtensionContextActions - for ctx.* in event handlers
{
getModel: () => this.session.model,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
shutdown: () => {
this.shutdownRequested = true;
},
getContextUsage: () => this.session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.executeCompaction(options?.customInstructions, false);
if (result) {
options?.onComplete?.(result);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - for ctx.* in command handlers
{
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
@ -808,31 +763,16 @@ export class InteractiveMode {
return { cancelled: false };
},
},
uiContext,
);
// Subscribe to extension errors
extensionRunner.onError((error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
shutdownHandler: () => {
this.shutdownRequested = true;
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
// Set up extension-registered shortcuts
this.setupExtensionShortcuts(extensionRunner);
// Show loaded extensions (unless silenced)
if (!this.settingsManager.getQuietStartup()) {
const extensionPaths = extensionRunner.getExtensionPaths();
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
}
/**
@ -950,6 +890,38 @@ export class InteractiveMode {
this.renderWidgets();
}
private clearExtensionWidgets(): void {
for (const widget of this.extensionWidgetsAbove.values()) {
widget.dispose?.();
}
for (const widget of this.extensionWidgetsBelow.values()) {
widget.dispose?.();
}
this.extensionWidgetsAbove.clear();
this.extensionWidgetsBelow.clear();
this.renderWidgets();
}
private resetExtensionUI(): void {
if (this.extensionSelector) {
this.hideExtensionSelector();
}
if (this.extensionInput) {
this.hideExtensionInput();
}
if (this.extensionEditor) {
this.hideExtensionEditor();
}
this.ui.hideOverlay();
this.setExtensionFooter(undefined);
this.setExtensionHeader(undefined);
this.clearExtensionWidgets();
this.footerDataProvider.clearExtensionStatuses();
this.footer.invalidate();
this.setCustomEditorComponent(undefined);
this.defaultEditor.onExtensionShortcut = undefined;
}
// Maximum total widget lines to prevent viewport overflow
private static readonly MAX_WIDGET_LINES = 10;
@ -1608,6 +1580,11 @@ export class InteractiveMode {
await this.handleCompactCommand(customInstructions);
return;
}
if (text === "/reload") {
this.editor.setText("");
await this.handleReloadCommand();
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
@ -2143,7 +2120,6 @@ export class InteractiveMode {
}
renderInitialMessages(): void {
this.hasRenderedInitialMessages = true;
// Get aligned messages and entries from session context
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context, {
@ -3276,6 +3252,53 @@ export class InteractiveMode {
// Command handlers
// =========================================================================
private async handleReloadCommand(): Promise<void> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before reloading.");
return;
}
if (this.session.isCompacting) {
this.showWarning("Wait for compaction to finish before reloading.");
return;
}
this.resetExtensionUI();
const loader = new BorderedLoader(this.ui, theme, "Reloading resources...", { cancellable: false });
const previousEditor = this.editor;
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const restoreEditor = () => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(previousEditor);
this.ui.setFocus(previousEditor as Component);
this.ui.requestRender();
};
try {
await this.session.reload();
this.rebuildAutocomplete();
const runner = this.session.extensionRunner;
if (runner) {
this.setupExtensionShortcuts(runner);
}
restoreEditor();
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
const modelsJsonError = this.session.modelRegistry.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
this.showStatus("Reloaded resources");
} catch (error) {
restoreEditor();
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined;
@ -3632,12 +3655,13 @@ export class InteractiveMode {
private handleDebugCommand(): void {
const width = this.ui.terminal.columns;
const height = this.ui.terminal.rows;
const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath();
const debugData = [
`Debug output at ${new Date().toISOString()}`,
`Terminal width: ${width}`,
`Terminal: ${width}x${height}`,
`Total lines: ${allLines.length}`,
"",
"=== All rendered lines with visible widths ===",

View file

@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
// ============================================================================
export class Theme {
readonly name?: string;
readonly sourcePath?: string;
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
private mode: ColorMode;
@ -342,7 +344,10 @@ export class Theme {
fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
mode: ColorMode,
options: { name?: string; sourcePath?: string } = {},
) {
this.name = options.name;
this.sourcePath = options.sourcePath;
this.mode = mode;
this.fgColors = new Map();
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@ -457,6 +462,9 @@ export function getAvailableThemes(): string[] {
}
}
}
for (const name of registeredThemes.keys()) {
themes.add(name);
}
return Array.from(themes).sort();
}
@ -487,26 +495,16 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] {
}
}
for (const [name, theme] of registeredThemes.entries()) {
if (!result.some((t) => t.name === name)) {
result.push({ name, path: theme.sourcePath });
}
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
function loadThemeJson(name: string): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${name}: ${error}`);
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
const missingColors: string[] = [];
@ -522,12 +520,12 @@ function loadThemeJson(name: string): ThemeJson {
}
}
let errorMessage = `Invalid theme "${name}":\n`;
let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.length > 0) {
errorMessage += `\nMissing required color tokens:\n`;
errorMessage += "\nMissing required color tokens:\n";
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
throw new Error(errorMessage);
}
return json as ThemeJson;
}
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
function parseThemeJsonContent(label: string, content: string): ThemeJson {
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${label}: ${error}`);
}
return parseThemeJson(label, json);
}
function loadThemeJson(name: string): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
return parseThemeJsonContent(name, content);
}
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
const colorMode = mode ?? detectColorMode();
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
@ -558,10 +581,23 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
fgColors[key as ThemeColor] = value;
}
}
return new Theme(fgColors, bgColors, colorMode);
return new Theme(fgColors, bgColors, colorMode, {
name: themeJson.name,
sourcePath,
});
}
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {
const content = fs.readFileSync(themePath, "utf-8");
const themeJson = parseThemeJsonContent(themePath, content);
return createTheme(themeJson, mode, themePath);
}
function loadTheme(name: string, mode?: ColorMode): Theme {
const registeredTheme = registeredThemes.get(name);
if (registeredTheme) {
return registeredTheme;
}
const themeJson = loadThemeJson(name);
return createTheme(themeJson, mode);
}
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;
const registeredThemes = new Map<string, Theme>();
export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const theme of themes) {
if (theme.name) {
registeredThemes.set(theme.name, theme);
}
}
}
export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
const name = themeName ?? getDefaultTheme();

View file

@ -35,68 +35,11 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
console.log(JSON.stringify(header));
}
}
// Set up extensions for print mode (no UI, no command context)
// Set up extensions for print mode (no UI)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
await session.bindExtensions({
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
@ -119,14 +62,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
return { cancelled: result.cancelled };
},
},
// No UI context - hasUI will be false
);
extensionRunner.onError((err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
onError: (err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
},
});
}

View file

@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {
shutdownRequested = true;
},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
await session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
@ -340,14 +282,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return { cancelled: result.cancelled };
},
},
createExtensionUIContext(),
);
extensionRunner.onError((err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
shutdownHandler: () => {
shutdownRequested = true;
},
onError: (err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
});
}
@ -577,8 +517,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
async function checkShutdownRequested(): Promise<void> {
if (!shutdownRequested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
const currentRunner = session.extensionRunner;
if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
}
// Close readline interface to stop waiting for input

View file

@ -1,6 +1,8 @@
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getSettingsPath } from "../config.js";
import { getBinDir } from "../config.js";
import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
@ -95,6 +97,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig;
}
export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
const currentPath = process.env[pathKey] ?? "";
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const hasBinDir = pathEntries.includes(binDir);
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
return {
...process.env,
[pathKey]: updatedPath,
};
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:

View file

@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { API_KEY } from "./utilities.js";
import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession;
@ -61,7 +61,9 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
// Must subscribe to enable session persistence

View file

@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { API_KEY } from "./utilities.js";
import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
let session: AgentSession;
@ -67,7 +67,9 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
// Subscribe to track events

View file

@ -13,6 +13,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { createTestResourceLoader } from "./utilities.js";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@ -107,7 +108,9 @@ describe("AgentSession concurrent prompt guard", () => {
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
return session;
@ -197,7 +200,9 @@ describe("AgentSession concurrent prompt guard", () => {
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
// First prompt completes

View file

@ -163,6 +163,42 @@ describe("parseArgs", () => {
});
});
describe("--skill flag", () => {
test("parses single --skill", () => {
const result = parseArgs(["--skill", "./skill-dir"]);
expect(result.skills).toEqual(["./skill-dir"]);
});
test("parses multiple --skill flags", () => {
const result = parseArgs(["--skill", "./skill-a", "--skill", "./skill-b"]);
expect(result.skills).toEqual(["./skill-a", "./skill-b"]);
});
});
describe("--prompt-template flag", () => {
test("parses single --prompt-template", () => {
const result = parseArgs(["--prompt-template", "./prompts"]);
expect(result.promptTemplates).toEqual(["./prompts"]);
});
test("parses multiple --prompt-template flags", () => {
const result = parseArgs(["--prompt-template", "./one", "--prompt-template", "./two"]);
expect(result.promptTemplates).toEqual(["./one", "./two"]);
});
});
describe("--theme flag", () => {
test("parses single --theme", () => {
const result = parseArgs(["--theme", "./theme.json"]);
expect(result.themes).toEqual(["./theme.json"]);
});
test("parses multiple --theme flags", () => {
const result = parseArgs(["--theme", "./dark.json", "--theme", "./light.json"]);
expect(result.themes).toEqual(["./dark.json", "./light.json"]);
});
});
describe("--no-skills flag", () => {
test("parses --no-skills flag", () => {
const result = parseArgs(["--no-skills"]);
@ -170,6 +206,20 @@ describe("parseArgs", () => {
});
});
describe("--no-prompt-templates flag", () => {
test("parses --no-prompt-templates flag", () => {
const result = parseArgs(["--no-prompt-templates"]);
expect(result.noPromptTemplates).toBe(true);
});
});
describe("--no-themes flag", () => {
test("parses --no-themes flag", () => {
const result = parseArgs(["--no-themes"]);
expect(result.noThemes).toBe(true);
});
});
describe("--no-tools flag", () => {
test("parses --no-tools flag", () => {
const result = parseArgs(["--no-tools"]);

View file

@ -13,7 +13,6 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import {
createExtensionRuntime,
type Extension,
ExtensionRunner,
type SessionBeforeCompactEvent,
type SessionCompactEvent,
type SessionEvent,
@ -22,13 +21,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { createTestResourceLoader } from "./utilities.js";
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
describe.skipIf(!API_KEY)("Compaction extensions", () => {
let session: AgentSession;
let tempDir: string;
let extensionRunner: ExtensionRunner;
let capturedEvents: SessionEvent[];
beforeEach(() => {
@ -101,51 +100,18 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const modelRegistry = new ModelRegistry(authStorage);
const runtime = createExtensionRuntime();
extensionRunner = new ExtensionRunner(extensions, runtime, tempDir, sessionManager, modelRegistry);
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: async () => {},
sendUserMessage: async () => {},
appendEntry: async () => {},
setSessionName: () => {},
getSessionName: () => undefined,
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: () => {},
setModel: async () => false,
getThinkingLevel: () => "off",
setThinkingLevel: () => {},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
);
const resourceLoader = {
...createTestResourceLoader(),
getExtensions: () => ({ extensions, errors: [], runtime }),
};
session = new AgentSession({
agent,
sessionManager,
settingsManager,
extensionRunner,
cwd: tempDir,
modelRegistry,
resourceLoader,
});
return session;

View file

@ -19,7 +19,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { API_KEY, getRealAuthStorage, hasAuthForProvider, resolveApiKey } from "./utilities.js";
import {
API_KEY,
createTestResourceLoader,
getRealAuthStorage,
hasAuthForProvider,
resolveApiKey,
} from "./utilities.js";
// Check for auth
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
@ -81,7 +87,9 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
session.subscribe(() => {});
@ -175,7 +183,9 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
session.subscribe(() => {});

View file

View file

@ -0,0 +1,161 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultPackageManager, type ProgressEvent } from "../src/core/package-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
describe("DefaultPackageManager", () => {
let tempDir: string;
let settingsManager: SettingsManager;
let packageManager: DefaultPackageManager;
beforeEach(() => {
tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
const agentDir = join(tempDir, "agent");
mkdirSync(agentDir, { recursive: true });
settingsManager = SettingsManager.inMemory();
packageManager = new DefaultPackageManager({
cwd: tempDir,
agentDir,
settingsManager,
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe("resolve", () => {
it("should return empty paths when no sources configured", async () => {
const result = await packageManager.resolve();
expect(result.extensions).toEqual([]);
expect(result.skills).toEqual([]);
expect(result.prompts).toEqual([]);
expect(result.themes).toEqual([]);
});
it("should resolve local extension paths from settings", async () => {
const extPath = join(tempDir, "my-extension.ts");
writeFileSync(extPath, "export default function() {}");
settingsManager.setExtensionPaths([extPath]);
const result = await packageManager.resolve();
expect(result.extensions).toContain(extPath);
});
it("should resolve skill paths from settings", async () => {
const skillDir = join(tempDir, "skills");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
`---
name: test-skill
description: A test skill
---
Content`,
);
settingsManager.setSkillPaths([skillDir]);
const result = await packageManager.resolve();
expect(result.skills).toContain(skillDir);
});
});
describe("resolveExtensionSources", () => {
it("should resolve local paths", async () => {
const extPath = join(tempDir, "ext.ts");
writeFileSync(extPath, "export default function() {}");
const result = await packageManager.resolveExtensionSources([extPath]);
expect(result.extensions).toContain(extPath);
});
it("should handle directories with pi manifest", async () => {
const pkgDir = join(tempDir, "my-package");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "my-package",
pi: {
extensions: ["./src/index.ts"],
skills: ["./skills"],
},
}),
);
mkdirSync(join(pkgDir, "src"), { recursive: true });
writeFileSync(join(pkgDir, "src", "index.ts"), "export default function() {}");
mkdirSync(join(pkgDir, "skills"), { recursive: true });
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions).toContain(join(pkgDir, "src", "index.ts"));
expect(result.skills).toContain(join(pkgDir, "skills"));
});
it("should handle directories with auto-discovery layout", async () => {
const pkgDir = join(tempDir, "auto-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "themes"), { recursive: true });
writeFileSync(join(pkgDir, "extensions", "main.ts"), "export default function() {}");
writeFileSync(join(pkgDir, "themes", "dark.json"), "{}");
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions).toContain(join(pkgDir, "extensions"));
expect(result.themes).toContain(join(pkgDir, "themes"));
});
});
describe("progress callback", () => {
it("should emit progress events", async () => {
const events: ProgressEvent[] = [];
packageManager.setProgressCallback((event) => events.push(event));
const extPath = join(tempDir, "ext.ts");
writeFileSync(extPath, "export default function() {}");
// Local paths don't trigger install progress, but we can verify the callback is set
await packageManager.resolveExtensionSources([extPath]);
// For now just verify no errors - npm/git would trigger actual events
expect(events.length).toBe(0);
});
});
describe("source parsing", () => {
it("should emit progress events on install attempt", async () => {
const events: ProgressEvent[] = [];
packageManager.setProgressCallback((event) => events.push(event));
// Use public install method which emits progress events
try {
await packageManager.install("npm:nonexistent-package@1.0.0");
} catch {
// Expected to fail - package doesn't exist
}
// Should have emitted start event before failure
expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true);
// Should have emitted error event
expect(events.some((e) => e.type === "error")).toBe(true);
});
it("should recognize github URLs without git: prefix", async () => {
const events: ProgressEvent[] = [];
packageManager.setProgressCallback((event) => events.push(event));
// This should be parsed as a git source, not throw "unsupported"
try {
await packageManager.install("https://github.com/nonexistent/repo");
} catch {
// Expected to fail - repo doesn't exist
}
// Should have attempted clone, not thrown unsupported error
expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true);
});
});
});

View file

@ -0,0 +1,231 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
import type { Skill } from "../src/core/skills.js";
describe("DefaultResourceLoader", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `rl-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 });
});
describe("reload", () => {
it("should initialize with empty results before reload", () => {
const loader = new DefaultResourceLoader({ cwd, agentDir });
expect(loader.getExtensions().extensions).toEqual([]);
expect(loader.getSkills().skills).toEqual([]);
expect(loader.getPrompts().prompts).toEqual([]);
expect(loader.getThemes().themes).toEqual([]);
});
it("should discover skills from agentDir", async () => {
const skillsDir = join(agentDir, "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(
join(skillsDir, "test-skill.md"),
`---
name: test-skill
description: A test skill
---
Skill content here.`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
expect(skills.some((s) => s.name === "test-skill")).toBe(true);
});
it("should discover prompts from agentDir", async () => {
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(
join(promptsDir, "test-prompt.md"),
`---
description: A test prompt
---
Prompt content.`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { prompts } = loader.getPrompts();
expect(prompts.some((p) => p.name === "test-prompt")).toBe(true);
});
it("should discover AGENTS.md context files", async () => {
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { agentsFiles } = loader.getAgentsFiles();
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
});
it("should discover SYSTEM.md from cwd/.pi", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "You are a helpful assistant.");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });
writeFileSync(join(piDir, "APPEND_SYSTEM.md"), "Additional instructions.");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getAppendSystemPrompt()).toContain("Additional instructions.");
});
});
describe("noSkills option", () => {
it("should skip skill discovery when noSkills is true", async () => {
const skillsDir = join(agentDir, "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(
join(skillsDir, "test-skill.md"),
`---
name: test-skill
description: A test skill
---
Content`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir, noSkills: true });
await loader.reload();
const { skills } = loader.getSkills();
expect(skills).toEqual([]);
});
it("should still load additional skill paths when noSkills is true", async () => {
const customSkillDir = join(tempDir, "custom-skills");
mkdirSync(customSkillDir, { recursive: true });
writeFileSync(
join(customSkillDir, "custom.md"),
`---
name: custom
description: Custom skill
---
Content`,
);
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
additionalSkillPaths: [customSkillDir],
});
await loader.reload();
const { skills } = loader.getSkills();
expect(skills.some((s) => s.name === "custom")).toBe(true);
});
});
describe("override functions", () => {
it("should apply skillsOverride", async () => {
const injectedSkill: Skill = {
name: "injected",
description: "Injected skill",
filePath: "/fake/path",
baseDir: "/fake",
source: "custom",
};
const loader = new DefaultResourceLoader({
cwd,
agentDir,
skillsOverride: () => ({
skills: [injectedSkill],
diagnostics: [],
}),
});
await loader.reload();
const { skills } = loader.getSkills();
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("injected");
});
it("should apply systemPromptOverride", async () => {
const loader = new DefaultResourceLoader({
cwd,
agentDir,
systemPromptOverride: () => "Custom system prompt",
});
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Custom system prompt");
});
});
describe("extension conflict detection", () => {
it("should detect tool conflicts between extensions", async () => {
// Create two extensions that register the same tool
const ext1Dir = join(agentDir, "extensions", "ext1");
const ext2Dir = join(agentDir, "extensions", "ext2");
mkdirSync(ext1Dir, { recursive: true });
mkdirSync(ext2Dir, { recursive: true });
writeFileSync(
join(ext1Dir, "index.ts"),
`
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function(pi: ExtensionAPI) {
pi.registerTool({
name: "duplicate-tool",
description: "First",
parameters: Type.Object({}),
execute: async () => ({ result: "1" }),
});
}`,
);
writeFileSync(
join(ext2Dir, "index.ts"),
`
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function(pi: ExtensionAPI) {
pi.registerTool({
name: "duplicate-tool",
description: "Second",
parameters: Type.Object({}),
execute: async () => ({ result: "2" }),
});
}`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { errors } = loader.getExtensions();
expect(errors.some((e) => e.error.includes("duplicate-tool") && e.error.includes("conflicts"))).toBe(true);
});
});
});

View file

@ -2,6 +2,8 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { createAgentSession } from "../src/core/sdk.js";
import { SessionManager } from "../src/core/session-manager.js";
@ -47,21 +49,30 @@ This is a test skill.
expect(session.skills.some((s) => s.name === "test-skill")).toBe(true);
});
it("should have empty skills when options.skills is empty array (--no-skills)", async () => {
it("should have empty skills when resource loader returns none (--no-skills)", async () => {
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.inMemory(),
skills: [], // Explicitly empty - like --no-skills
resourceLoader,
});
// session.skills should be empty
expect(session.skills).toEqual([]);
// No warnings since we didn't discover
expect(session.skillWarnings).toEqual([]);
});
it("should use provided skills when options.skills is explicitly set", async () => {
it("should use provided skills when resource loader supplies them", async () => {
const customSkill = {
name: "custom-skill",
description: "A custom skill",
@ -70,16 +81,25 @@ This is a test skill.
source: "custom" as const,
};
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [customSkill], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.inMemory(),
skills: [customSkill],
resourceLoader,
});
// session.skills should contain only the provided skill
expect(session.skills).toEqual([customSkill]);
// No warnings since we didn't discover
expect(session.skillWarnings).toEqual([]);
});
});

View file

@ -254,152 +254,44 @@ describe("skills", () => {
});
describe("loadSkills with options", () => {
it("should load from customDirectories only when built-ins disabled", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
it("should load from explicit skillPaths", () => {
const { skills, warnings } = loadSkills({
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: [join(fixturesDir, "valid-skill")],
});
expect(skills.length).toBeGreaterThan(0);
expect(skills.every((s) => s.source === "custom")).toBe(true);
expect(skills).toHaveLength(1);
expect(skills[0].source).toBe("custom");
expect(warnings).toHaveLength(0);
});
it("should filter out ignoredSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [join(fixturesDir, "valid-skill")],
ignoredSkills: ["valid-skill"],
it("should warn when skill path does not exist", () => {
const { skills, warnings } = loadSkills({
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: ["/non/existent/path"],
});
expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("does not exist"))).toBe(true);
});
it("should support glob patterns in ignoredSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
ignoredSkills: ["valid-*"],
});
expect(skills.every((s) => !s.name.startsWith("valid-"))).toBe(true);
});
it("should have ignoredSkills take precedence over includeSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-*"],
ignoredSkills: ["valid-skill"],
});
// valid-skill should be excluded even though it matches includeSkills
expect(skills.every((s) => s.name !== "valid-skill")).toBe(true);
});
it("should expand ~ in customDirectories", () => {
it("should expand ~ in skillPaths", () => {
const homeSkillsDir = join(homedir(), ".pi/agent/skills");
const { skills: withTilde } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: ["~/.pi/agent/skills"],
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: ["~/.pi/agent/skills"],
});
const { skills: withoutTilde } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [homeSkillsDir],
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: [homeSkillsDir],
});
expect(withTilde.length).toBe(withoutTilde.length);
});
it("should return empty when all sources disabled and no custom dirs", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
});
expect(skills).toHaveLength(0);
});
it("should filter skills with includeSkills glob patterns", () => {
// Load all skills from fixtures
const { skills: allSkills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
});
expect(allSkills.length).toBeGreaterThan(0);
// Filter to only include "valid-skill"
const { skills: filtered } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-skill"],
});
expect(filtered).toHaveLength(1);
expect(filtered[0].name).toBe("valid-skill");
});
it("should support glob patterns in includeSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-*"],
});
expect(skills.length).toBeGreaterThan(0);
expect(skills.every((s) => s.name.startsWith("valid-"))).toBe(true);
});
it("should return all skills when includeSkills is empty", () => {
const { skills: withEmpty } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: [],
});
const { skills: withoutOption } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
});
expect(withEmpty.length).toBe(withoutOption.length);
});
});
describe("collision handling", () => {

View file

@ -9,7 +9,9 @@ import { Agent } from "@mariozechner/pi-agent-core";
import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
@ -172,6 +174,19 @@ export interface TestSessionContext {
cleanup: () => void;
}
export function createTestResourceLoader(): ResourceLoader {
return {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
}
/**
* Create an AgentSession for testing with proper setup and cleanup.
* Use this for e2e tests that need real LLM calls.
@ -204,7 +219,9 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
// Must subscribe to enable session persistence

View file

@ -4,9 +4,11 @@ import {
AgentSession,
AuthStorage,
convertToLlm,
createExtensionRuntime,
formatSkillsForPrompt,
loadSkillsFromDir,
ModelRegistry,
type ResourceLoader,
SessionManager,
type Skill,
} from "@mariozechner/pi-coding-agent";
@ -448,12 +450,28 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
}
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
// Create AgentSession wrapper
const session = new AgentSession({
agent,
sessionManager,
settingsManager: settingsManager as any,
cwd: process.cwd(),
modelRegistry,
resourceLoader,
baseToolsOverride,
});
// Mutable per-run state - event handler references this

19
test.sh
View file

@ -33,9 +33,28 @@ unset XAI_API_KEY
unset OPENROUTER_API_KEY
unset ZAI_API_KEY
unset MISTRAL_API_KEY
unset MINIMAX_API_KEY
unset MINIMAX_CN_API_KEY
unset AI_GATEWAY_API_KEY
unset OPENCODE_API_KEY
unset COPILOT_GITHUB_TOKEN
unset GH_TOKEN
unset GITHUB_TOKEN
unset GOOGLE_APPLICATION_CREDENTIALS
unset GOOGLE_CLOUD_PROJECT
unset GCLOUD_PROJECT
unset GOOGLE_CLOUD_LOCATION
unset AWS_PROFILE
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
unset AWS_REGION
unset AWS_DEFAULT_REGION
unset AWS_BEARER_TOKEN_BEDROCK
unset AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
unset AWS_CONTAINER_CREDENTIALS_FULL_URI
unset AWS_WEB_IDENTITY_TOKEN_FILE
unset BEDROCK_EXTENSIVE_MODEL_TEST
echo "Running tests without API keys..."
npm test