# 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/<type>/`
- `<cwd>/.pi/<type>/`

## 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 <path|npm:|git:>      # add hook source (repeatable)
pi --tool <path|npm:|git:>      # add custom tool source (repeatable)
pi --skill <path|npm:|git:>     # add skill source (repeatable)
pi --theme <path|npm:|git:>     # add theme source (repeatable)
```

**Installation locations for npm/git sources:**

| Source | Install location |
|--------|------------------|
| CLI flags | `/tmp/pi-extensions/<type>/npm/` or `git/` |
| Global settings (`~/.pi/agent/settings.json`) | `~/.pi/agent/<type>/npm/` or `git/` |
| Project settings (`<cwd>/.pi/settings.json`) | `<cwd>/.pi/<type>/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` → `<cwd>/.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**: `<cwd>/.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/<type>/npm/` or `git/`
- **Global settings.json**: `~/.pi/agent/<type>/npm/` or `git/`
- **Project settings.json**: `<cwd>/.pi/<type>/npm/` or `git/`

### Atomic Installation

To prevent corrupted state from interrupted installs (Ctrl+C):
1. Install to `<target>.installing/` (temporary)
2. On success, atomically rename to `<target>/`
3. If interrupted, `<target>/` 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 `<target>/` exists and has matching version in package.json → skip install
4. Otherwise:
   - Remove stale `<target>.installing/` if exists
   - `npm pack @scope/pkg@1.2.3` → download tarball
   - Extract to `<target>.installing/`
   - If `package.json` has `dependencies` → run `npm install`
   - Rename `<target>.installing/` → `<target>/`

### 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 `<target>/` exists → skip clone
4. Otherwise:
   - Remove stale `<target>.installing/` if exists
   - `git clone <url>` to `<target>.installing/`
   - `git checkout <tag|commit|branch>`
   - If `package.json` has `dependencies` → run `npm install`
   - Rename `<target>.installing/` → `<target>/`

## 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@<version>`: 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/<type>/`, `./.pi/<type>/`
   - settings.json `<type>.paths`
   - CLI `--<type>` arguments

2. **Install remote sources:**
   - Process `npm:` and `git:` specifiers
   - Install to `~/.pi/agent/<type>/npm/` or `git/`

3. **Scan all sources:**
   - Recursively discover extensions
   - Compute relative path for each

4. **Apply filter:**
   - Combine settings.json `<type>.filter` and CLI `--<type>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<ExtensionType, ExtensionTypeConfig> = {
  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<string>;
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<T> {
  extensions: T[];
  errors: Array<{ path: string; error: string }>;
}

export async function discoverExtensions(
  options: LoadExtensionsOptions,
): Promise<DiscoveredExtension[]>;
```

### `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<LoadHooksResult> {
  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
