Files
oh-my-pi/packages/coding-agent/src/core/extensions/loader.ts
T
can1357 e6d33e0a48 feat(coding-agent): converted capability loading and discovery providers to async APIs with caching fs layer
- Converted SettingsManager.create(), loadSettings(), discoverSkills(), loadSlashCommands(), buildSystemPrompt(), loadSkills(), loadProjectContextFiles(), and getShellConfig() from synchronous to asynchronous APIs.
- Changed capability provider load() method from supporting both sync and async to async-only by removing loadSync().
- Removed fs property from LoadContext interface in capability types.
- Added new fs.ts module with caching layer for filesystem operations using readFile, readDirEntries, readDir, walkUp, and cache management functions.
- Refactored all discovery providers (builtin, claude, cline, codex, cursor, gemini, github, mcp-json, ssh, vscode, windsurf) to use async file operations with Promise.all for parallel loading.
2026-01-11 04:26:43 +01:00

440 lines
11 KiB
TypeScript

/**
* Extension loader - loads TypeScript extension modules using native Bun import.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import * as path from "node:path";
import type { KeyId } from "@oh-my-pi/pi-tui";
import * as TypeBox from "@sinclair/typebox";
import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module";
import { loadCapability } from "../../discovery";
import { getExtensionNameFromPath } from "../../discovery/helpers";
import * as piCodingAgent from "../../index";
import { createEventBus, type EventBus } from "../event-bus";
import type { ExecOptions } from "../exec";
import { execCommand } from "../exec";
import { logger } from "../logger";
import type {
Extension,
ExtensionAPI,
ExtensionContext,
ExtensionFactory,
ExtensionRuntime,
LoadExtensionsResult,
MessageRenderer,
RegisteredCommand,
ToolDefinition,
} from "./types";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Create a runtime with throwing stubs for action methods.
* Runner.initialize() replaces these with real implementations.
*/
export function createExtensionRuntime(): ExtensionRuntime {
const notInitialized = () => {
throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
};
return {
sendMessage: notInitialized,
sendUserMessage: notInitialized,
appendEntry: notInitialized,
getActiveTools: notInitialized,
getAllTools: notInitialized,
setActiveTools: notInitialized,
setModel: () => Promise.reject(new Error("Extension runtime not initialized")),
getThinkingLevel: notInitialized,
setThinkingLevel: notInitialized,
flagValues: new Map(),
};
}
/**
* Create the ExtensionAPI for an extension.
* Registration methods write to the extension object.
* Action methods delegate to the shared runtime.
*/
function createExtensionAPI(
extension: Extension,
runtime: ExtensionRuntime,
cwd: string,
eventBus: EventBus,
): ExtensionAPI {
const api = {
logger,
typebox: TypeBox,
pi: piCodingAgent,
on(event: string, handler: HandlerFn): void {
const list = extension.handlers.get(event) ?? [];
list.push(handler);
extension.handlers.set(event, list);
},
registerTool(tool: ToolDefinition): void {
extension.tools.set(tool.name, {
definition: tool,
extensionPath: extension.path,
});
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
extension.commands.set(name, { name, ...options });
},
registerShortcut(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
},
): void {
extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options });
},
registerFlag(
name: string,
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
): void {
extension.flags.set(name, { name, extensionPath: extension.path, ...options });
if (options.default !== undefined) {
runtime.flagValues.set(name, options.default);
}
},
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {
extension.messageRenderers.set(customType, renderer as MessageRenderer);
},
getFlag(name: string): boolean | string | undefined {
if (!extension.flags.has(name)) return undefined;
return runtime.flagValues.get(name);
},
sendMessage(message, options): void {
runtime.sendMessage(message, options);
},
sendUserMessage(content, options): void {
runtime.sendUserMessage(content, options);
},
appendEntry(customType: string, data?: unknown): void {
runtime.appendEntry(customType, data);
},
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
getActiveTools(): string[] {
return runtime.getActiveTools();
},
getAllTools(): string[] {
return runtime.getAllTools();
},
setActiveTools(toolNames: string[]): void {
runtime.setActiveTools(toolNames);
},
setModel(model) {
return runtime.setModel(model);
},
getThinkingLevel() {
return runtime.getThinkingLevel();
},
setThinkingLevel(level) {
runtime.setThinkingLevel(level);
},
events: eventBus,
} as ExtensionAPI;
return api;
}
/**
* Create an Extension object with empty collections.
*/
function createExtension(extensionPath: string, resolvedPath: string): Extension {
return {
path: extensionPath,
resolvedPath,
handlers: new Map(),
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),
};
}
async function loadExtension(
extensionPath: string,
cwd: string,
eventBus: EventBus,
runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd);
try {
const module = await import(resolvedPath);
const factory = (module.default ?? module) as ExtensionFactory;
if (typeof factory !== "function") {
return {
extension: null,
error: `Extension does not export a valid factory function: ${extensionPath}`,
};
}
const extension = createExtension(extensionPath, resolvedPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
await factory(api);
return { extension, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { extension: null, error: `Failed to load extension: ${message}` };
}
}
/**
* Create an Extension from an inline factory function.
*/
export async function loadExtensionFromFactory(
factory: ExtensionFactory,
cwd: string,
eventBus: EventBus,
runtime: ExtensionRuntime,
name = "<inline>",
): Promise<Extension> {
const extension = createExtension(name, name);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
await factory(api);
return extension;
}
/**
* Load extensions from paths.
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
if (error) {
errors.push({ path: extPath, error });
continue;
}
if (extension) {
extensions.push(extension);
}
}
return {
extensions,
errors,
runtime,
};
}
interface ExtensionManifest {
extensions?: string[];
themes?: string[];
skills?: string[];
}
function readExtensionManifest(packageJsonPath: string): ExtensionManifest | null {
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { omp?: ExtensionManifest; pi?: ExtensionManifest };
const manifest = pkg.omp ?? pkg.pi;
if (manifest && typeof manifest === "object") {
return manifest;
}
return null;
} catch {
return null;
}
}
function isExtensionFile(name: string): boolean {
return name.endsWith(".ts") || name.endsWith(".js");
}
/**
* Resolve extension entry points from a directory.
*/
function resolveExtensionEntries(dir: string): string[] | null {
const packageJsonPath = path.join(dir, "package.json");
if (existsSync(packageJsonPath)) {
const manifest = readExtensionManifest(packageJsonPath);
if (manifest?.extensions?.length) {
const entries: string[] = [];
for (const extPath of manifest.extensions) {
const resolvedExtPath = path.resolve(dir, extPath);
if (existsSync(resolvedExtPath)) {
entries.push(resolvedExtPath);
}
}
if (entries.length > 0) {
return entries;
}
}
}
const indexTs = path.join(dir, "index.ts");
const indexJs = path.join(dir, "index.js");
if (existsSync(indexTs)) {
return [indexTs];
}
if (existsSync(indexJs)) {
return [indexJs];
}
return null;
}
/**
* Discover extensions in a directory.
*
* Discovery rules:
* 1. Direct files: `extensions/*.ts` or `*.js` → load
* 2. Subdirectory with index: `extensions/<ext>/index.ts` or `index.js` → load
* 3. Subdirectory with package.json: `extensions/<ext>/package.json` with "omp"/"pi" field → load declared paths
*
* No recursion beyond one level. Complex packages must use package.json manifest.
*/
function discoverExtensionsInDir(dir: string): string[] {
if (!existsSync(dir)) {
return [];
}
const discovered: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
// 1. Direct files: *.ts or *.js
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
discovered.push(entryPath);
continue;
}
// 2 & 3. Subdirectories
if (entry.isDirectory() || entry.isSymbolicLink()) {
const entries = resolveExtensionEntries(entryPath);
if (entries) {
discovered.push(...entries);
}
}
}
} catch {
return [];
}
return discovered;
}
/**
* Discover and load extensions from standard locations.
*/
export async function discoverAndLoadExtensions(
configuredPaths: string[],
cwd: string,
eventBus?: EventBus,
disabledExtensionIds: string[] = [],
): Promise<LoadExtensionsResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
const disabled = new Set(disabledExtensionIds);
const isDisabledName = (name: string): boolean => disabled.has(`extension-module:${name}`);
const addPath = (extPath: string): void => {
const resolved = path.resolve(extPath);
if (!seen.has(resolved)) {
seen.add(resolved);
allPaths.push(extPath);
}
};
const addPaths = (paths: string[]) => {
for (const extPath of paths) {
if (isDisabledName(getExtensionNameFromPath(extPath))) continue;
addPath(extPath);
}
};
// 1. Discover extension modules via capability API (native .omp/.pi only)
const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, { cwd });
for (const ext of discovered.items) {
if (ext._source.provider !== "native") continue;
if (isDisabledName(ext.name)) continue;
addPath(ext.path);
}
// 2. Explicitly configured paths
for (const configuredPath of configuredPaths) {
const resolved = resolvePath(configuredPath, cwd);
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
const entries = resolveExtensionEntries(resolved);
if (entries) {
addPaths(entries);
continue;
}
const discovered = discoverExtensionsInDir(resolved);
if (discovered.length > 0) {
addPaths(discovered);
}
continue;
}
addPath(resolved);
}
return loadExtensions(allPaths, cwd, eventBus);
}