co-mono/packages/coding-agent/docs/extension-loading.md
Mario Zechner 31294e4749 Add extension loading design doc
Unified system for loading hooks, tools, skills, and themes from:
- Local files and directories
- npm packages (npm:pkg@version)
- Git repositories (git:url@tag or git:url#branch)

Includes filtering, atomic installation, and settings hierarchy.
2025-12-26 20:35:04 +01:00

962 lines
27 KiB
Markdown

# 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