From b846a4bfcf168ae218dbeb0fecc24a98c0b3e332 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 20 Jan 2026 23:34:53 +0100 Subject: [PATCH] feat(coding-agent): ResourceLoader, package management, and /reload command (#645) - Add ResourceLoader interface and DefaultResourceLoader implementation - Add PackageManager for npm/git extension sources with install/remove/update - Add session.reload() and session.bindExtensions() APIs - Add /reload command in interactive mode - Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates - Add pi install/remove/update commands for extension management - Refactor settings.json to use arrays for skills, prompts, themes - Remove legacy SkillsSettings source flags and filters - Update SDK examples and documentation for ResourceLoader pattern - Add theme registration and loadThemeFromPath for dynamic themes - Add getShellEnv to include bin dir in PATH for bash commands --- .pi/skills/pi-tmux-test.md | 64 --- packages/coding-agent/CHANGELOG.md | 16 + packages/coding-agent/README.md | 74 ++- packages/coding-agent/docs/extensions.md | 20 +- packages/coding-agent/docs/sdk.md | 287 ++++------ packages/coding-agent/docs/skills.md | 80 +-- .../examples/sdk/02-custom-model.ts | 6 +- .../examples/sdk/03-custom-prompt.ts | 28 +- .../coding-agent/examples/sdk/04-skills.ts | 50 +- .../examples/sdk/06-extensions.ts | 21 +- .../examples/sdk/07-context-files.ts | 43 +- .../examples/sdk/08-prompt-templates.ts | 30 +- .../examples/sdk/09-api-keys-and-oauth.ts | 17 +- .../coding-agent/examples/sdk/10-settings.ts | 6 +- .../examples/sdk/12-full-control.ts | 22 +- packages/coding-agent/examples/sdk/README.md | 54 +- packages/coding-agent/src/cli/args.ts | 31 +- .../coding-agent/src/core/agent-session.ts | 374 ++++++++++-- .../coding-agent/src/core/auth-storage.ts | 5 +- .../coding-agent/src/core/bash-executor.ts | 3 +- .../src/core/extensions/loader.ts | 4 +- .../src/core/extensions/runner.ts | 37 +- .../src/core/footer-data-provider.ts | 5 + .../coding-agent/src/core/model-registry.ts | 4 +- .../coding-agent/src/core/package-manager.ts | 536 ++++++++++++++++++ .../coding-agent/src/core/prompt-templates.ts | 142 +++-- .../coding-agent/src/core/resource-loader.ts | 516 +++++++++++++++++ packages/coding-agent/src/core/sdk.ts | 418 ++------------ .../coding-agent/src/core/settings-manager.ts | 118 ++-- packages/coding-agent/src/core/skills.ts | 165 +++--- .../coding-agent/src/core/system-prompt.ts | 153 +---- packages/coding-agent/src/core/tools/bash.ts | 3 +- packages/coding-agent/src/index.ts | 15 +- packages/coding-agent/src/main.ts | 257 +++++---- .../interactive/components/bordered-loader.ts | 54 +- .../src/modes/interactive/interactive-mode.ts | 284 +++++----- .../src/modes/interactive/theme/theme.ts | 92 ++- packages/coding-agent/src/modes/print-mode.ts | 74 +-- .../coding-agent/src/modes/rpc/rpc-mode.ts | 83 +-- packages/coding-agent/src/utils/shell.ts | 16 + .../test/agent-session-branching.test.ts | 4 +- .../test/agent-session-compaction.test.ts | 4 +- .../test/agent-session-concurrent.test.ts | 5 + packages/coding-agent/test/args.test.ts | 50 ++ .../test/compaction-extensions.test.ts | 48 +- .../test/compaction-thinking-model.test.ts | 12 +- packages/coding-agent/test/sdk-skills.test.ts | 36 +- packages/coding-agent/test/skills.test.ts | 156 +---- packages/coding-agent/test/utilities.ts | 17 + packages/mom/src/agent.ts | 18 + test.sh | 19 + 51 files changed, 2724 insertions(+), 1852 deletions(-) delete mode 100644 .pi/skills/pi-tmux-test.md create mode 100644 packages/coding-agent/src/core/package-manager.ts create mode 100644 packages/coding-agent/src/core/resource-loader.ts diff --git a/.pi/skills/pi-tmux-test.md b/.pi/skills/pi-tmux-test.md deleted file mode 100644 index d79761fa..00000000 --- a/.pi/skills/pi-tmux-test.md +++ /dev/null @@ -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` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e9ee7135..53025b79 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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`, and `pi update` 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 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index a6f35ee3..0c9e97a7 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 `. 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 ` or `-e ` +- Settings: `extensions` array supports file paths and `npm:` or `git:` sources +- CLI: `--extension ` or `-e ` (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,14 @@ await ctx.ui.custom((tui, theme, done) => ({ pi [options] [@files...] [messages...] ``` +### Commands + +| Command | Description | +|---------|-------------| +| `install [-l]` | Install extension source and add to settings (`-l` for project) | +| `remove [-l]` | Remove extension source from settings | +| `update [source]` | Update installed extensions (skips pinned sources) | + ### Options | Option | Description | @@ -1236,8 +1268,12 @@ pi [options] [@files...] [messages...] | `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` | | `--extension `, `-e` | Load an extension file (can be used multiple times) | | `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) | +| `--skill ` | Load a skill file or directory (can be used multiple times) | +| `--prompt-template ` | Load a prompt template file or directory (can be used multiple times) | +| `--theme ` | Load a theme file or directory (can be used multiple times) | | `--no-skills` | Disable skills discovery and loading | -| `--skills ` | 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 [output]` | Export session to HTML | | `--help`, `-h` | Show help | | `--version`, `-v` | Show version | @@ -1339,10 +1375,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 +1397,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. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 44d6780d..5149ef95 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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 diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a2052783..33d6b0d6 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -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: `/.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 ``` diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index e086c952..c95c66b9 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -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. `/.claude/skills/*/SKILL.md` (Claude Code project, one level) -4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive) -5. `/.pi/skills/**/SKILL.md` (Pi project, recursive) +1. `~/.pi/agent/skills/` (global) +2. `/.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 `/.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 `/.claude/skills/` | -| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` | -| `enablePiProject` | `true` | Load from `/.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. diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts index 5d5bf656..25da4461 100644 --- a/packages/coding-agent/examples/sdk/02-custom-model.ts +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -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"); diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts index 37698f46..f4bd521e 100644 --- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -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(), }); diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 6fcb2a2a..4bffa5fb 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -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"); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index 9bec2822..9c95ec4c 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -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(), }); diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts index 61aa871a..50390b50 100644 --- a/packages/coding-agent/examples/sdk/07-context-files.ts +++ b/packages/coding-agent/examples/sdk/07-context-files.ts @@ -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: [] diff --git a/packages/coding-agent/examples/sdk/08-prompt-templates.ts b/packages/coding-agent/examples/sdk/08-prompt-templates.ts index bc3cbff5..d417a801 100644 --- a/packages/coding-agent/examples/sdk/08-prompt-templates.ts +++ b/packages/coding-agent/examples/sdk/08-prompt-templates.ts @@ -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: [] diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 22cbf98b..2e074d08 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -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(), diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index a5451e2e..fac2bc5e 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -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(); diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 2d361b8e..c366b967 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -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, }); diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index 43872409..7de0e00e 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -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 diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3d24c77d..8fb80dec 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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 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,11 @@ export function printHelp(): void { ${chalk.bold("Usage:")} ${APP_NAME} [options] [@files...] [messages...] +${chalk.bold("Commands:")} + ${APP_NAME} install [-l] Install extension source and add to settings + ${APP_NAME} remove [-l] Remove extension source from settings + ${APP_NAME} update [source] Update installed extensions (skips pinned sources) + ${chalk.bold("Options:")} --provider Provider name (default: google) --model Model ID (default: gemini-2.5-flash) @@ -183,8 +202,12 @@ ${chalk.bold("Options:")} --thinking Set thinking level: off, minimal, low, medium, high, xhigh --extension, -e Load an extension file (can be used multiple times) --no-extensions Disable extension discovery (explicit -e paths still work) + --skill Load a skill file or directory (can be used multiple times) --no-skills Disable skills discovery and loading - --skills Comma-separated glob patterns to filter skills (e.g., git-*,docker) + --prompt-template Load a prompt template file or directory (can be used multiple times) + --no-prompt-templates Disable prompt template discovery and loading + --theme Load a theme file or directory (can be used multiple times) + --no-themes Disable theme discovery and loading --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --help, -h Show this help diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a686018d..f5c86f03 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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; 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; + /** 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; - /** 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; + /** 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; 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 | undefined; + private _resourceLoader: ResourceLoader; + private _customTools: ToolDefinition[]; + private _baseToolRegistry: Map = new Map(); + private _cwd: string; + private _extensionRunnerRef?: { current?: ExtensionRunner }; + private _initialActiveToolNames?: string[]; + private _baseToolsOverride?: Record; + private _extensionUIContext?: ExtensionUIContext; + private _extensionCommandContextActions?: ExtensionCommandContextActions; + private _extensionShutdownHandler?: ShutdownHandler; + private _extensionErrorListeners = new Set(); + private _extensionErrorUnsubscribers: Array<() => void> = []; // Model registry for API key resolution private _modelRegistry: ModelRegistry; // Tool registry for extension getTools/setTools - private _toolRegistry: Map; - - // Function to rebuild system prompt when tools change - private _rebuildSystemPrompt?: (toolNames: string[]) => string; + private _toolRegistry: Map = 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 { - 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 | 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 ?? "", + 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 { + 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: "", + event: "send_message", + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + sendUserMessage: (content, options) => { + this.sendUserMessage(content, options).catch((err) => { + runner.emitError({ + extensionPath: "", + 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; + 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: "" })), + ]; + 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(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 { + 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 // ========================================================================= diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 09c0ac02..7042c137 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -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 = new Map(); private fallbackResolver?: (provider: string) => string | undefined; - constructor(private authPath: string) { + constructor(private authPath: string = join(getAgentDir(), "auth.json")) { this.reload(); } diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 5db79685..b2498218 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -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"], }); diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d15e7e63..df10eda5 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -101,7 +101,7 @@ type HandlerFn = (...args: unknown[]) => Promise; /** * 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 { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 637e902e..289400a0 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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 { + return new Map(this.runtime.flagValues); + } + getShortcuts(effectiveKeybindings: Required): Map { const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings); const extensionShortcuts = new Map(); @@ -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; diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 41f7aea1..3ad6a20d 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -85,6 +85,11 @@ export class FooterDataProvider { } } + /** Internal: clear extension statuses */ + clearExtensionStatuses(): void { + this.extensionStatuses.clear(); + } + /** Internal: cleanup */ dispose(): void { if (this.gitWatcher) { diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 5ccec0d7..c957685e 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -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) => { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts new file mode 100644 index 00000000..2bec5096 --- /dev/null +++ b/packages/coding-agent/src/core/package-manager.ts @@ -0,0 +1,536 @@ +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 PackageManager { + resolve(onMissing?: (source: string) => Promise): Promise; + install(source: string, options?: { local?: boolean }): Promise; + remove(source: string, options?: { local?: boolean }): Promise; + update(source?: string): Promise; + resolveExtensionSources( + sources: string[], + options?: { local?: boolean; temporary?: boolean }, + ): Promise; +} + +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; + skills: Set; + prompts: Set; + themes: Set; +} + +export class DefaultPackageManager implements PackageManager { + private cwd: string; + private agentDir: string; + private settingsManager: SettingsManager; + private globalNpmRoot: string | undefined; + + constructor(options: PackageManagerOptions) { + this.cwd = options.cwd; + this.agentDir = options.agentDir; + this.settingsManager = options.settingsManager; + } + + async resolve(onMissing?: (source: string) => Promise): Promise { + 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 { + 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 { + const parsed = this.parseSource(source); + const scope: SourceScope = options?.local ? "project" : "global"; + if (parsed.type === "npm") { + await this.installNpm(parsed, scope, false); + return; + } + if (parsed.type === "git") { + await this.installGit(parsed, scope, false); + return; + } + throw new Error(`Unsupported install source: ${source}`); + } + + async remove(source: string, options?: { local?: boolean }): Promise { + const parsed = this.parseSource(source); + const scope: SourceScope = options?.local ? "project" : "global"; + if (parsed.type === "npm") { + await this.uninstallNpm(parsed, scope); + return; + } + if (parsed.type === "git") { + await this.removeGit(parsed, scope, false); + return; + } + throw new Error(`Unsupported remove source: ${source}`); + } + + async update(source?: string): Promise { + 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 { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + if (parsed.pinned) return; + await this.installNpm(parsed, scope, false); + return; + } + if (parsed.type === "git") { + if (parsed.pinned) return; + await this.updateGit(parsed, scope, false); + return; + } + } + + private async resolveExtensionSourcesInternal( + sources: Array<{ source: string; scope: SourceScope }>, + accumulator: ResourceAccumulator, + onMissing?: (source: string) => Promise, + ): Promise { + for (const { source, scope } of sources) { + const parsed = this.parseSource(source); + if (parsed.type === "local") { + this.resolveLocalExtensionSource(parsed, accumulator); + continue; + } + + const installMissing = async (): Promise => { + 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 { + if (parsed.type === "npm") { + await this.installNpm(parsed, scope, scope === "temporary"); + return; + } + if (parsed.type === "git") { + await this.installGit(parsed, scope, scope === "temporary"); + 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), + }; + } + + if (source.startsWith("git:")) { + const repoSpec = source.slice("git:".length).trim(); + const [repo, ref] = repoSpec.split("@"); + const normalized = repo.replace(/^https?:\/\//, ""); + 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 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 { + 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 { + 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, temporary: boolean): Promise { + const targetDir = this.getGitInstallPath(source, scope, temporary); + 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 }); + } + } + + private async updateGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise { + const targetDir = this.getGitInstallPath(source, scope, temporary); + if (!existsSync(targetDir)) { + await this.installGit(source, scope, temporary); + return; + } + await this.runCommand("git", ["pull"], { cwd: targetDir }); + } + + private async removeGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise { + const targetDir = this.getGitInstallPath(source, scope, temporary); + if (!existsSync(targetDir)) return; + rmSync(targetDir, { recursive: true, force: true }); + } + + private ensureNpmProject(installRoot: string): void { + if (!existsSync(installRoot)) { + mkdirSync(installRoot, { recursive: true }); + } + 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 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, temporary?: boolean): string { + if (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): void { + if (!entries) return; + for (const entry of entries) { + const resolved = resolve(root, entry); + this.addPath(target, resolved); + } + } + + private addPath(set: Set, value: string): void { + if (!value) return; + set.add(value); + } + + private createAccumulator(): ResourceAccumulator { + return { + extensions: new Set(), + skills: new Set(), + prompts: new Set(), + themes: new Set(), + }; + } + + 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 { + 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(); + } +} diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index e5732189..51e5338d 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -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>(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>(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; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts new file mode 100644 index 00000000..a8be472b --- /dev/null +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -0,0 +1,516 @@ +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; +} + +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(); + + 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 { + 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); + 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(); + + 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 = ``; + 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; + } +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7abb6db8..c1befe7d 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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; 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 { - 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 { 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, - ``, - ); - 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: "" })) ?? []), - ]; - - // 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(); - 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 | 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(); - 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, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 9562badc..a3faf227 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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): 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 { - 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(); } diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 2a922596..489e5848 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -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, "'"); } -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 { diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 5a7b6cba..9a577d72 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -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 = { +const toolDescriptions: Record = { 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 = { 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(); - - // 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) diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 9a9b1870..3c312e52 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -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"], }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7dbd741d..7d4339de 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -98,6 +98,7 @@ export type { WidgetPlacement, } from "./core/extensions/index.js"; export { + createExtensionRuntime, ExtensionRunner, isBashToolResult, isEditToolResult, @@ -115,10 +116,10 @@ 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 { 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 +134,6 @@ export { createReadOnlyTools, createReadTool, createWriteTool, - // Discovery - discoverAuthStorage, - discoverContextFiles, - discoverExtensions, - discoverModels, - discoverPromptTemplates, - discoverSkills, - loadSettings, type PromptTemplate, // Pre-built tools (use process.cwd()) readOnlyTools, @@ -172,9 +165,7 @@ export { type CompactionSettings, type ImageSettings, type RetrySettings, - type Settings, SettingsManager, - type SkillsSettings, } from "./core/settings-manager.js"; // Skills export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 52da527c..6e75697f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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,118 @@ async function readPipedStdin(): Promise { }); } +type PackageCommand = "install" | "remove" | "update"; + +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") { + 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 { + 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 }); + + 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(`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(`Removed ${options.source}`); + return true; + } + + await packageManager.update(options.source); + if (options.source) { + console.log(`Updated ${options.source}`); + } else { + console.log("Updated extensions"); + } + return true; +} + async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -173,58 +284,15 @@ async function createSessionManager(parsed: Args, cwd: string): Promise `${defaultPrompt}\n\n${resolvedAppendPrompt}`; - } - // Tools if (parsed.noTools) { // --no-tools: start with no built-in tools @@ -298,60 +357,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(); for (const ext of extensionsResult.extensions) { for (const [name, flag] of ext.flags) { @@ -361,7 +412,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 +443,6 @@ export async function main(args: string[]) { // Prepend stdin content to messages parsed.messages.unshift(stdinContent); } - time("readPipedStdin"); } if (parsed.export) { @@ -415,11 +464,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 +477,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 +491,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 +499,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 +513,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.")); diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index 1a91af03..4286f024 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -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(); + } } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 59d755c7..21b05afc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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,63 @@ export class InteractiveMode { // Extension System // ========================================================================= + 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", ` ${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", ` ${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", ` ${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", ` ${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 { - // 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 +737,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 +864,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 +1554,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 +2094,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 +3226,53 @@ export class InteractiveMode { // Command handlers // ========================================================================= + private async handleReloadCommand(): Promise { + 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 { const parts = text.split(/\s+/); const outputPath = parts.length > 1 ? parts[1] : undefined; @@ -3632,12 +3629,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 ===", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 23823963..583245bb 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -334,6 +334,8 @@ function resolveThemeColors>( // ============================================================================ export class Theme { + readonly name?: string; + readonly sourcePath?: string; private fgColors: Map; private bgColors: Map; private mode: ColorMode; @@ -342,7 +344,10 @@ export class Theme { fgColors: Record, bgColors: Record, 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 = {} as Record; @@ -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(); + +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(); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 1a079718..ec411058 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -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}`); + }, }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 39f0ef17..440b9e65 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise { // 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 { 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 { async function checkShutdownRequested(): Promise { 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 diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index b3f583aa..62ff558a 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -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: diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 00aabbd1..a6f161f4 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -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 diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 14a664bb..5a03a2a7 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -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 diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 0d0a7c31..0115af77 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -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 { @@ -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 diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 76341b9e..2438de2d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -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"]); diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 4789e268..9b7bf8be 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -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; diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts index 85624d2f..ea679cfe 100644 --- a/packages/coding-agent/test/compaction-thinking-model.test.ts +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -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(() => {}); diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index dbf1e57e..4934ebbb 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -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([]); }); }); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index ba1a9a06..74e7d855 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index acea2e01..2b7c8c2d 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -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 diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index fc222fb9..cafe4bce 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -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 diff --git a/test.sh b/test.sh index b4e29b20..52d9ed82 100755 --- a/test.sh +++ b/test.sh @@ -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