diff --git a/packages/coding-agent/docs/extension-loading.md b/packages/coding-agent/docs/extension-loading.md new file mode 100644 index 00000000..d9d82e53 --- /dev/null +++ b/packages/coding-agent/docs/extension-loading.md @@ -0,0 +1,962 @@ +# Extension Loading + +Unified system for loading hooks, tools, skills, and themes from local files, directories, npm packages, and git repositories. + +## Extension Types + +| Type | Root Entry | Subdir Entry | Purpose | +|------|------------|--------------|---------| +| Hooks | `*.ts` / `*.js` | `index.ts` / `index.js` / package.json `main` | Event handlers for agent lifecycle | +| Tools | `*.ts` / `*.js` | `index.ts` / `index.js` / package.json `main` | Custom tools for the agent | +| Skills | `*.md` | `SKILL.md` | Context/instructions loaded into agent | +| Themes | `*.theme.json` | `*.theme.json` (recursive) | Color schemes for TUI | + +**Note:** Themes use `*.theme.json` pattern scanned recursively at all levels, allowing flat theme packs without requiring subdirectories. + +## Sources + +Extensions can be loaded from: + +### File Paths +``` +./my-hook.ts +~/global-hook.ts +/absolute/path/hook.ts +``` + +### Directories +``` +./my-hooks/ +~/.pi/agent/hooks/ +``` + +### npm Packages +``` +npm:package-name +npm:package-name@1.2.3 +npm:package-name@latest +npm:@scope/package-name +npm:@scope/package-name@1.2.3 +``` + +### Git Repositories +``` +git:https://github.com/user/repo +git:https://github.com/user/repo@v1.0.0 # tag +git:https://github.com/user/repo@abc123f # commit +git:https://github.com/user/repo#branch # branch +``` + +## Storage Layout + +### Permanent (settings.json) +``` +~/.pi/agent/ + hooks/ + my-local-hook.ts # root-level file + complex-hook/ # directory with entry point + index.ts + utils.ts + npm/ + my-hook@1.2.3/ # npm package + package.json + node_modules/ + index.js + @scope/ + scoped-hook@2.0.0/ + ... + git/ + github.com/user/repo@v1.0.0/ # git repo + ... + tools/ + ... (same structure) + skills/ + ... (same structure, but SKILL.md instead of index.ts) + themes/ + dark.theme.json # root-level theme + light.theme.json + community-pack/ # theme pack (no entry point needed) + nord.theme.json + dracula.theme.json + npm/ + cool-themes@1.0.0/ + monokai.theme.json + solarized.theme.json +``` + +### Ephemeral (CLI flags) +``` +/tmp/pi-extensions/ + hooks/ + npm/ + my-hook@1.0.0/ + git/ + github.com/user/repo@v1.0.0/ + tools/ + ... + skills/ + ... + themes/ + ... +``` + +Temp directory persists until OS clears `/tmp/`. No re-download needed across sessions (usually). + +## Entry Point Resolution + +For each discovered directory, resolve entry point in order: + +### Hooks & Tools +1. `index.ts` (if exists) +2. `index.js` (if exists) +3. `main` field in `package.json` (if exists) + +### Skills +1. `SKILL.md` (required) + +### Themes +Themes use recursive pattern matching instead of fixed entry points: +- Scan recursively for `*.theme.json` files at all levels +- Each matching file is a separate theme +- Path derived from filename (e.g., `dark.theme.json` → `dark`, `pack/nord.theme.json` → `pack/nord`) + +## Scanning Algorithm + +``` +scan(baseDir, config): + results = [] + + for entry in baseDir: + skip if entry.name starts with "." + skip if entry.name == "node_modules" + skip if entry.name ends with ".installing" + + if entry is file: + if matches rootPattern (e.g., *.ts, *.js, *.md, *.theme.json): + results.add(entry) + + if entry is directory: + if config.recursivePattern: + # For themes: scan recursively for *.theme.json everywhere + results.addAll(scan(directory, config)) + else if has entryPoint (index.ts, index.js, SKILL.md): + results.add(directory) # load as single extension + else: + results.addAll(scan(directory, config)) # recurse to find extensions + + return results +``` + +**Default directories scanned (always, regardless of settings.json):** +- `~/.pi/agent//` +- `/.pi//` + +## Extension Packs + +A key use case is pulling in a **pack** (collection) of extensions via a directory, npm package, or git repo, then filtering to a subset. + +**Example: Skill pack** +``` +npm:pi-skills@1.0.0 contains: + skills/ + brave-search/SKILL.md + browser-tools/SKILL.md + transcribe/SKILL.md + youtube-transcript/SKILL.md + ... (10+ skills) +``` + +You want only 2 of them: +```json +{ + "skills": { + "paths": ["npm:pi-skills@1.0.0"], + "filter": ["brave-search", "youtube-transcript"] + } +} +``` + +**Example: Theme pack** +``` +npm:community-themes@1.0.0 contains: + themes/ + nord.theme.json + dracula.theme.json + solarized-dark.theme.json + solarized-light.theme.json + monokai.theme.json +``` + +Exclude solarized variants: +```json +{ + "themes": { + "paths": ["npm:community-themes@1.0.0"], + "filter": ["!solarized-*"] + } +} +``` + +**Example: Hook pack** +``` +npm:audit-hooks@1.0.0 contains: + hooks/ + file-audit/index.ts + command-audit/index.ts + network-audit/index.ts + debug-logger/index.ts +``` + +All except debug: +```json +{ + "hooks": { + "paths": ["npm:audit-hooks@1.0.0"], + "filter": ["!debug-*"] + } +} +``` + +## Filtering + +Single filter array with `!` prefix for exclusion. Patterns are matched against extension paths (directory or filename without extension). + +```json +{ + "filter": ["pattern1", "pattern2", "!excluded-pattern"] +} +``` + +**Logic:** +1. Collect all patterns without `!` prefix → include patterns +2. Collect all patterns with `!` prefix → exclude patterns +3. If include patterns exist: start with extensions matching any include pattern +4. If no include patterns: start with all extensions +5. Remove extensions matching any exclude pattern + +**Examples:** +```json +["brave-search"] // only brave-search +["brave-*", "docker"] // brave-search, brave-api, docker +["!transcribe"] // all except transcribe +["audit-*", "!audit-debug"] // audit-* except audit-debug +``` + +Patterns are glob patterns matched against extension paths. + +## CLI Arguments + +### Adding Sources +```bash +pi --hook # add hook source (repeatable) +pi --tool # add custom tool source (repeatable) +pi --skill # add skill source (repeatable) +pi --theme # add theme source (repeatable) +``` + +**Installation locations for npm/git sources:** + +| Source | Install location | +|--------|------------------| +| CLI flags | `/tmp/pi-extensions//npm/` or `git/` | +| Global settings (`~/.pi/agent/settings.json`) | `~/.pi/agent//npm/` or `git/` | +| Project settings (`/.pi/settings.json`) | `/.pi//npm/` or `git/` | + +File/directory paths are used directly (no installation). + +- **CLI = ephemeral**: cached in temp until OS clears `/tmp/` +- **Global settings = permanent**: installed to user's agent directory +- **Project settings = project-local**: installed to project's `.pi/` directory + +Examples: +- `--hook npm:my-hook@1.0.0` → `/tmp/pi-extensions/hooks/npm/my-hook@1.0.0/` +- Global settings.json `npm:my-hook@1.0.0` → `~/.pi/agent/hooks/npm/my-hook@1.0.0/` +- Project settings.json `npm:my-hook@1.0.0` → `/.pi/hooks/npm/my-hook@1.0.0/` + +This encourages: try via CLI, if you like it, add to settings.json for permanent install. + +### Filtering +```bash +pi --hooks "pattern1,pattern2,!excluded" # filter hooks +pi --custom-tools "pattern1,!excluded" # filter custom tools +pi --skills "pattern1,pattern2" # filter skills +pi --themes "pattern1" # filter themes +``` + +### Disabling +```bash +pi --no-hooks # disable all hooks +pi --no-custom-tools # disable all custom tools +pi --no-skills # disable all skills (already exists) +``` + +### Built-in Tools +```bash +pi --tools read,bash,edit,write # select which built-in tools to enable (unchanged) +``` + +## Settings Hierarchy + +Extensions are configured in settings.json at two levels: +- **Global**: `~/.pi/agent/settings.json` +- **Project**: `/.pi/settings.json` + +**Merge behavior:** +- `paths`: **additive** - project paths are added to global paths +- `filter`: **override** - project filter replaces global filter if specified + +**Example:** +```json +// Global: ~/.pi/agent/settings.json +{ + "hooks": { + "paths": ["npm:audit-hooks@1.0.0"], + "filter": ["!debug-*"] + } +} + +// Project: .pi/settings.json +{ + "hooks": { + "paths": ["./project-hooks/"], + "filter": ["audit-*"] // overrides global filter + } +} + +// Effective: +{ + "hooks": { + "paths": ["npm:audit-hooks@1.0.0", "./project-hooks/"], + "filter": ["audit-*"] + } +} +``` + +## settings.json Structure + +```json +{ + "hooks": { + "paths": [ + "./my-hooks/", + "npm:@scope/hook@1.0.0", + "git:https://github.com/user/hooks@v1.0.0" + ], + "filter": ["audit-*", "!audit-debug"] + }, + "tools": { + "paths": ["npm:cool-tools@2.0.0"], + "filter": ["!dangerous-tool"] + }, + "skills": { + "paths": ["npm:pi-skills@1.0.0", "~/my-skills/"], + "filter": ["brave-search", "git-*", "!git-legacy"] + }, + "themes": { + "paths": ["npm:community-themes@1.0.0"] + } +} +``` + +**Migration from current format:** +- `hooks: string[]` → `hooks.paths: string[]` +- `customTools: string[]` → `tools.paths: string[]` +- `skills.customDirectories` → `skills.paths` +- `skills.includeSkills` → `skills.filter` (patterns without `!`) +- `skills.ignoredSkills` → `skills.filter` (patterns with `!` prefix) + +## Installation Flow + +Target directory depends on source: +- **CLI flags**: `/tmp/pi-extensions//npm/` or `git/` +- **Global settings.json**: `~/.pi/agent//npm/` or `git/` +- **Project settings.json**: `/.pi//npm/` or `git/` + +### Atomic Installation + +To prevent corrupted state from interrupted installs (Ctrl+C): +1. Install to `.installing/` (temporary) +2. On success, atomically rename to `/` +3. If interrupted, `/` doesn't exist → next run retries cleanly +4. Scanner filters out `*.installing` directories (see Scanning Algorithm) + +### npm Packages +1. Parse specifier: `npm:@scope/pkg@1.2.3` → name: `@scope/pkg`, version: `1.2.3` +2. Determine target dir based on source (CLI → temp, global → agent dir, project → cwd/.pi/) +3. If `/` exists and has matching version in package.json → skip install +4. Otherwise: + - Remove stale `.installing/` if exists + - `npm pack @scope/pkg@1.2.3` → download tarball + - Extract to `.installing/` + - If `package.json` has `dependencies` → run `npm install` + - Rename `.installing/` → `/` + +### Git Repositories +1. Parse specifier: `git:https://github.com/user/repo@v1.0.0` +2. Determine target dir based on source (CLI → temp, global → agent dir, project → cwd/.pi/) +3. If `/` exists → skip clone +4. Otherwise: + - Remove stale `.installing/` if exists + - `git clone ` to `.installing/` + - `git checkout ` + - If `package.json` has `dependencies` → run `npm install` + - Rename `.installing/` → `/` + +## Update Command + +```bash +pi update # update all extensions +pi update hooks # update only hooks +pi update tools skills # update specific types +``` + +**Behavior:** +- For `npm:pkg@`: check if newer version of that exact spec exists (e.g., `@latest` resolves to newer) +- For `git:repo#branch`: `git pull` +- For `git:repo@tag` or `git:repo@commit`: no-op (pinned) +- Local files/directories: no-op + +## Loading Flow (Full) + +1. **Collect sources:** + - Default directories: `~/.pi/agent//`, `./.pi//` + - settings.json `.paths` + - CLI `--` arguments + +2. **Install remote sources:** + - Process `npm:` and `git:` specifiers + - Install to `~/.pi/agent//npm/` or `git/` + +3. **Scan all sources:** + - Recursively discover extensions + - Compute relative path for each + +4. **Apply filter:** + - Combine settings.json `.filter` and CLI `--s` patterns + - Filter by path (no loading yet) + +5. **Load survivors:** + - Parse/execute only extensions that passed filter + - Validate (frontmatter, exports, schema) + - Report errors for invalid extensions + +--- + +# Implementation Plan + +## Overview + +This implementation consolidates four separate loading systems (hooks, tools, skills, themes) into a unified extension loading framework with shared logic for source resolution, installation, scanning, filtering, and loading. + +## New Files + +### `src/core/extensions/types.ts` +Extension type definitions shared across all loaders. + +```typescript +export type ExtensionType = "hooks" | "tools" | "skills" | "themes"; + +export interface ExtensionSource { + type: "file" | "directory" | "npm" | "git"; + specifier: string; // original specifier from config/CLI + resolvedPath?: string; // resolved local path after install +} + +export interface DiscoveredExtension { + path: string; // relative path (e.g., "brave-search", "npm/@scope/pkg@1.0.0") + absolutePath: string; // absolute filesystem path + entryPoint: string; // resolved entry point file + source: ExtensionSource; +} + +export interface ExtensionConfig { + paths?: string[]; + filter?: string[]; +} + +export interface ExtensionTypeConfig { + rootPatterns: string[]; // e.g., ["*.ts", "*.js"] + subdirEntryPoints: string[]; // e.g., ["index.ts", "index.js"] + packageJsonFallback: boolean; // whether to check package.json main +} + +export const EXTENSION_CONFIGS: Record = { + hooks: { + rootPatterns: ["*.ts", "*.js"], + subdirEntryPoints: ["index.ts", "index.js"], + packageJsonFallback: true, + recursivePattern: false, + }, + tools: { + rootPatterns: ["*.ts", "*.js"], + subdirEntryPoints: ["index.ts", "index.js"], + packageJsonFallback: true, + recursivePattern: false, + }, + skills: { + rootPatterns: ["*.md"], + subdirEntryPoints: ["SKILL.md"], + packageJsonFallback: false, + recursivePattern: false, + }, + themes: { + rootPatterns: ["*.theme.json"], + subdirEntryPoints: [], // not used + packageJsonFallback: false, + recursivePattern: true, // scan for *.theme.json at all levels + }, +}; +``` + +### `src/core/extensions/source-resolver.ts` +Handles parsing and installing npm/git sources. + +```typescript +export function parseSource(specifier: string): ExtensionSource; +export type InstallLocation = "cli" | "global" | "project"; + +export async function installSource( + source: ExtensionSource, + type: ExtensionType, + location: InstallLocation, + cwd: string, // needed for project-local installs +): Promise; +export function isRemoteSource(specifier: string): boolean; +export function getInstallDir(type: ExtensionType, location: InstallLocation, cwd: string): string; +``` + +Key functions: +- `parseNpmSpecifier(spec)`: Parse `npm:@scope/pkg@1.2.3` → `{ name, version }` +- `parseGitSpecifier(spec)`: Parse `git:url@tag` or `git:url#branch` +- `installNpmPackage(name, version, targetDir)`: `npm pack` + extract + `npm install` +- `installGitRepo(url, ref, targetDir)`: `git clone` + checkout + `npm install` +- `getTargetDir(type, ephemeral)`: Returns temp dir or agent dir based on source + +### `src/core/extensions/scanner.ts` +Unified recursive scanning for all extension types. + +```typescript +export function scanDirectory( + baseDir: string, + config: ExtensionTypeConfig, +): DiscoveredExtension[]; + +export function resolveEntryPoint( + dir: string, + config: ExtensionTypeConfig, +): string | null; + +export function getRelativePath( + absolutePath: string, + baseDir: string, + config: ExtensionTypeConfig, +): string; // strips entry point filename and extension +``` + +### `src/core/extensions/filter.ts` +Filter logic using glob patterns with `!` exclusion. Matches against `extension.path`. + +```typescript +export function applyFilter( + extensions: DiscoveredExtension[], + patterns: string[], +): DiscoveredExtension[]; + +export function parseFilterPatterns(patterns: string[]): { + include: string[]; + exclude: string[]; +}; + +export function matchesPattern(path: string, pattern: string): boolean; +``` + +### `src/core/extensions/loader.ts` +Main entry point coordinating the full loading flow. + +```typescript +export interface LoadExtensionsOptions { + type: ExtensionType; + cwd: string; + agentDir: string; + globalPaths: string[]; // from global settings.json → install to agentDir + projectPaths: string[]; // from project settings.json → install to cwd/.pi/ + cliPaths: string[]; // from CLI flags → install to /tmp/ + filter: string[]; // combined filter patterns +} + +export interface LoadExtensionsResult { + extensions: T[]; + errors: Array<{ path: string; error: string }>; +} + +export async function discoverExtensions( + options: LoadExtensionsOptions, +): Promise; +``` + +### `src/core/extensions/index.ts` +Public exports. + +## Modified Files + +### `src/config.ts` + +Add directory getters: + +```typescript +export function getHooksDir(): string { + return join(getAgentDir(), "hooks"); +} + +export function getSkillsDir(): string { + return join(getAgentDir(), "skills"); +} + +// getToolsDir() already exists +// getThemesDir() = bundled themes (in package) +// getCustomThemesDir() = ~/.pi/agent/themes/ (user themes) - already exists +``` + +### `src/core/settings-manager.ts` + +Update `Settings` interface: + +```typescript +// Old: +hooks?: string[]; +customTools?: string[]; +skills?: SkillsSettings; + +// New: +hooks?: ExtensionConfig; +tools?: ExtensionConfig; +skills?: ExtensionConfig; // simplified from SkillsSettings +themes?: ExtensionConfig; +``` + +Add migration logic for old format: + +```typescript +function migrateSettings(settings: unknown): Settings { + // Convert hooks: string[] → hooks: { paths: string[] } + // Convert customTools: string[] → tools: { paths: string[] } + // Convert skills.customDirectories → skills.paths + // Convert skills.includeSkills/ignoredSkills → skills.filter +} +``` + +Add unified getters: + +```typescript +getExtensionConfig(type: ExtensionType): ExtensionConfig; +getExtensionPaths(type: ExtensionType): string[]; +getExtensionFilter(type: ExtensionType): string[]; +``` + +Update merge logic (paths are additive, filter overrides): + +```typescript +function mergeExtensionConfig(global: ExtensionConfig, project: ExtensionConfig): ExtensionConfig { + return { + paths: [...(global.paths ?? []), ...(project.paths ?? [])], // additive + filter: project.filter ?? global.filter, // override + }; +} +``` + +### `src/cli/args.ts` + +Update `Args` interface: + +```typescript +// Built-in tools (unchanged) +tools?: ToolName[]; // --tools read,bash,edit,write + +// Source flags +hooks?: string[]; // --hook (existing, repeatable) +customTools?: string[]; // --tool (existing, repeatable) +skills?: string[]; // --skill (new, repeatable) +themes?: string[]; // --theme (new, repeatable) + +// Filter flags +hooksFilter?: string[]; // --hooks "patterns" +customToolsFilter?: string[];// --custom-tools "patterns" +skillsFilter?: string[]; // --skills "patterns" (existing) +themesFilter?: string[]; // --themes "patterns" + +// Disable flags +noHooks?: boolean; // --no-hooks +noCustomTools?: boolean; // --no-custom-tools +noSkills?: boolean; // --no-skills (existing) +``` + +Update argument parsing: + +```typescript +// --tools (built-in tools, unchanged) +} else if (arg === "--tools" && i + 1 < args.length) { + // ... existing logic for built-in tools + +// --tool (add custom tool source) +} else if (arg === "--tool" && i + 1 < args.length) { + result.customTools = result.customTools ?? []; + result.customTools.push(args[++i]); + +// --custom-tools (filter custom tools) +} else if (arg === "--custom-tools" && i + 1 < args.length) { + result.customToolsFilter = args[++i].split(",").map(s => s.trim()); + +// --no-custom-tools +} else if (arg === "--no-custom-tools") { + result.noCustomTools = true; + +// --skill (add source) - new +} else if (arg === "--skill" && i + 1 < args.length) { + result.skills = result.skills ?? []; + result.skills.push(args[++i]); + +// --theme (add source) - new +} else if (arg === "--theme" && i + 1 < args.length) { + result.themes = result.themes ?? []; + result.themes.push(args[++i]); + +// --themes (filter) - new +} else if (arg === "--themes" && i + 1 < args.length) { + result.themesFilter = args[++i].split(",").map(s => s.trim()); + +// --hooks (filter) - new +} else if (arg === "--hooks" && i + 1 < args.length) { + result.hooksFilter = args[++i].split(",").map(s => s.trim()); + +// --no-hooks - new +} else if (arg === "--no-hooks") { + result.noHooks = true; +``` + +Add `pi update` command handling. + +### `src/core/hooks/loader.ts` + +Refactor to use extension system: + +```typescript +import { discoverExtensions, type DiscoveredExtension } from "../extensions/index.js"; + +export async function discoverAndLoadHooks( + options: { + cwd: string; + agentDir?: string; + configuredPaths?: string[]; + cliPaths?: string[]; + filter?: string[]; + } +): Promise { + const discovered = await discoverExtensions({ + type: "hooks", + defaultDirs: [join(agentDir, "hooks"), join(cwd, ".pi", "hooks")], + configuredPaths: options.configuredPaths ?? [], + cliPaths: options.cliPaths ?? [], + filter: options.filter ?? [], + }); + + // Load each discovered hook using existing jiti logic + const results = await Promise.all( + discovered.map(ext => loadHook(ext.entryPoint, cwd)) + ); + + // ... rest of existing logic +} +``` + +Remove duplicate code: +- `expandPath()` → use from extensions/source-resolver.ts +- `resolveHookPath()` → use from extensions/scanner.ts +- Discovery logic → use discoverExtensions() + +### `src/core/custom-tools/loader.ts` + +Same refactoring pattern as hooks/loader.ts. + +### `src/core/skills.ts` + +Refactor `loadSkills()` and `loadSkillsFromDir()`: + +```typescript +export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { + const discovered = await discoverExtensions({ + type: "skills", + defaultDirs: [ + // existing default dirs + join(homedir(), ".codex", "skills"), + join(homedir(), ".claude", "skills"), + join(agentDir, "skills"), + join(cwd, ".pi", "skills"), + ], + configuredPaths: options.paths ?? [], + cliPaths: options.cliPaths ?? [], + filter: options.filter ?? [], + }); + + // Load each discovered skill using existing parsing logic + // ... +} +``` + +Remove: +- `loadSkillsFromDirInternal()` recursive logic → use scanner.ts +- `matchesIncludePatterns()`/`matchesIgnorePatterns()` → use filter.ts + +### `src/modes/interactive/theme/theme.ts` + +Refactor `getAvailableThemes()` and `loadThemeJson()`: + +```typescript +export function getAvailableThemes(): string[] { + const discovered = discoverExtensions({ + type: "themes", + defaultDirs: [getThemesDir(), getCustomThemesDir()], + configuredPaths: settingsManager.getExtensionPaths("themes"), + cliPaths: [], // from args + filter: settingsManager.getExtensionFilter("themes"), + }); + + return discovered.map(ext => ext.path); +} +``` + +### `src/core/sdk.ts` + +Update to pass new options structure: + +```typescript +// Hooks +const { hooks, errors } = await discoverAndLoadHooks({ + cwd, + agentDir, + configuredPaths: settingsManager.getExtensionPaths("hooks"), + cliPaths: options.additionalHookPaths, + filter: [...settingsManager.getExtensionFilter("hooks"), ...(options.hooksFilter ?? [])], +}); + +// Tools +const result = await discoverAndLoadCustomTools({ + cwd, + agentDir, + configuredPaths: settingsManager.getExtensionPaths("tools"), + cliPaths: options.additionalToolPaths, + filter: [...settingsManager.getExtensionFilter("tools"), ...(options.toolsFilter ?? [])], + builtInToolNames: Object.keys(allTools), +}); +``` + +### `src/modes/interactive/interactive-mode.ts` + +Update skill loading to use new options structure. + +## Migration & Backwards Compatibility + +### Settings Migration + +When loading settings.json, detect old format and migrate: + +```typescript +// Old format detection +if (Array.isArray(settings.hooks)) { + settings.hooks = { paths: settings.hooks }; +} +if (Array.isArray(settings.customTools)) { + settings.tools = { paths: settings.customTools }; + delete settings.customTools; +} +if (settings.skills?.customDirectories) { + settings.skills.paths = settings.skills.customDirectories; + delete settings.skills.customDirectories; +} +// ... etc +``` + +### CLI Compatibility + +- `--tools` with built-in tool names still works (detected by checking if values match known tool names) +- Alternatively, deprecation warning and suggest `--builtin-tools` + +## Implementation Order + +1. **Phase 1: Core extension framework** + - Create `src/core/extensions/` directory + - Implement types.ts, scanner.ts, filter.ts + - Unit tests for scanning and filtering + +2. **Phase 2: Source resolution** + - Implement source-resolver.ts (npm + git) + - Add `npm pack` and `git clone` logic + - Unit tests for source parsing and installation + +3. **Phase 3: Settings migration** + - Update settings-manager.ts with new types + - Add migration logic + - Update config.ts with new directory getters + +4. **Phase 4: Refactor loaders** + - Refactor hooks/loader.ts + - Refactor custom-tools/loader.ts + - Refactor skills.ts + - Refactor theme.ts + - Remove duplicate code + +5. **Phase 5: CLI updates** + - Add new flags to args.ts + - Update help text + - Add `pi update` command + +6. **Phase 6: Integration** + - Update sdk.ts + - Update interactive-mode.ts + - End-to-end testing + +7. **Phase 7: Documentation** + - Update README.md + - Update docs/hooks.md, docs/custom-tools.md + - Add examples for npm/git extensions + +## Testing Strategy + +### Unit Tests +- `test/extensions/scanner.test.ts`: Directory scanning, entry point resolution +- `test/extensions/filter.test.ts`: Pattern matching, include/exclude logic +- `test/extensions/source-resolver.test.ts`: npm/git specifier parsing + +### Integration Tests +- `test/extensions/npm-install.test.ts`: Full npm package installation flow +- `test/extensions/git-clone.test.ts`: Full git repository cloning flow +- `test/extensions/loading.test.ts`: End-to-end extension discovery and loading + +### Migration Tests +- `test/settings-migration.test.ts`: Old → new settings format conversion + +## File Summary + +### New Files (7) +- `src/core/extensions/types.ts` +- `src/core/extensions/source-resolver.ts` +- `src/core/extensions/scanner.ts` +- `src/core/extensions/filter.ts` +- `src/core/extensions/loader.ts` +- `src/core/extensions/index.ts` +- `docs/extension-loading.md` (this file) + +### Modified Files (9) +- `src/config.ts` - add directory getters +- `src/core/settings-manager.ts` - new types, migration +- `src/cli/args.ts` - new flags, update parsing +- `src/core/hooks/loader.ts` - refactor to use extensions +- `src/core/custom-tools/loader.ts` - refactor to use extensions +- `src/core/skills.ts` - refactor to use extensions +- `src/modes/interactive/theme/theme.ts` - refactor to use extensions +- `src/core/sdk.ts` - update option passing +- `src/modes/interactive/interactive-mode.ts` - update skill loading + +### Deleted Code (moved to extensions/) +- Duplicate `expandPath()`, `normalizeUnicodeSpaces()` functions +- Duplicate discovery/scanning logic +- Duplicate path resolution logic