e6d33e0a48
- 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.
440 lines
11 KiB
TypeScript
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);
|
|
}
|