Files
clawdbot/src/plugins/discovery.ts
2026-01-20 11:22:28 +00:00

338 lines
9.4 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { ClawdbotPackageManifest, PackageManifest } from "./manifest.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
export type PluginCandidate = {
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
workspaceDir?: string;
packageName?: string;
packageVersion?: string;
packageDescription?: string;
packageDir?: string;
packageClawdbot?: ClawdbotPackageManifest;
};
export type PluginDiscoveryResult = {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
};
function isExtensionFile(filePath: string): boolean {
const ext = path.extname(filePath);
if (!EXTENSION_EXTS.has(ext)) return false;
return !filePath.endsWith(".d.ts");
}
function readPackageManifest(dir: string): PackageManifest | null {
const manifestPath = path.join(dir, "package.json");
if (!fs.existsSync(manifestPath)) return null;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
return JSON.parse(raw) as PackageManifest;
} catch {
return null;
}
}
function resolvePackageExtensions(manifest: PackageManifest): string[] {
const raw = manifest.clawdbot?.extensions;
if (!Array.isArray(raw)) return [];
return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function deriveIdHint(params: {
filePath: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.filePath, path.extname(params.filePath));
const rawPackageName = params.packageName?.trim();
if (!rawPackageName) return base;
// Prefer the unscoped name so config keys stay stable even when the npm
// package is scoped (example: @clawdbot/voice-call -> voice-call).
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
if (!params.hasMultipleExtensions) return unscoped;
return `${unscoped}/${base}`;
}
function addCandidate(params: {
candidates: PluginCandidate[];
seen: Set<string>;
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
workspaceDir?: string;
manifest?: PackageManifest | null;
packageDir?: string;
}) {
const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) return;
params.seen.add(resolved);
const manifest = params.manifest ?? null;
params.candidates.push({
idHint: params.idHint,
source: resolved,
rootDir: path.resolve(params.rootDir),
origin: params.origin,
workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined,
packageDescription: manifest?.description?.trim() || undefined,
packageDir: params.packageDir,
packageClawdbot: manifest?.clawdbot,
});
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
if (!fs.existsSync(params.dir)) return;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(params.dir, { withFileTypes: true });
} catch (err) {
params.diagnostics.push({
level: "warn",
message: `failed to read extensions dir: ${params.dir} (${String(err)})`,
source: params.dir,
});
return;
}
for (const entry of entries) {
const fullPath = path.join(params.dir, entry.name);
if (entry.isFile()) {
if (!isExtensionFile(fullPath)) continue;
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath,
rootDir: path.dirname(fullPath),
origin: params.origin,
workspaceDir: params.workspaceDir,
});
}
if (!entry.isDirectory()) continue;
const manifest = readPackageManifest(fullPath);
const extensions = manifest ? resolvePackageExtensions(manifest) : [];
if (extensions.length > 0) {
for (const extPath of extensions) {
const resolved = path.resolve(fullPath, extPath);
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
rootDir: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
continue;
}
const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"];
const indexFile = indexCandidates
.map((candidate) => path.join(fullPath, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: entry.name,
source: indexFile,
rootDir: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
}
}
function discoverFromPath(params: {
rawPath: string;
origin: PluginOrigin;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) {
params.diagnostics.push({
level: "error",
message: `plugin path not found: ${resolved}`,
source: resolved,
});
return;
}
const stat = fs.statSync(resolved);
if (stat.isFile()) {
if (!isExtensionFile(resolved)) {
params.diagnostics.push({
level: "error",
message: `plugin path is not a supported file: ${resolved}`,
source: resolved,
});
return;
}
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)),
source: resolved,
rootDir: path.dirname(resolved),
origin: params.origin,
workspaceDir: params.workspaceDir,
});
return;
}
if (stat.isDirectory()) {
const manifest = readPackageManifest(resolved);
const extensions = manifest ? resolvePackageExtensions(manifest) : [];
if (extensions.length > 0) {
for (const extPath of extensions) {
const source = path.resolve(resolved, extPath);
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: deriveIdHint({
filePath: source,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source,
rootDir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
}
return;
}
const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"];
const indexFile = indexCandidates
.map((candidate) => path.join(resolved, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: path.basename(resolved),
source: indexFile,
rootDir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
return;
}
discoverInDirectory({
dir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
});
return;
}
}
export function discoverClawdbotPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
}): PluginDiscoveryResult {
const candidates: PluginCandidate[] = [];
const diagnostics: PluginDiagnostic[] = [];
const seen = new Set<string>();
const workspaceDir = params.workspaceDir?.trim();
const extra = params.extraPaths ?? [];
for (const extraPath of extra) {
if (typeof extraPath !== "string") continue;
const trimmed = extraPath.trim();
if (!trimmed) continue;
discoverFromPath({
rawPath: trimmed,
origin: "config",
workspaceDir: workspaceDir?.trim() || undefined,
candidates,
diagnostics,
seen,
});
}
if (workspaceDir) {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceExt = path.join(workspaceRoot, ".clawdbot", "extensions");
discoverInDirectory({
dir: workspaceExt,
origin: "workspace",
workspaceDir: workspaceRoot,
candidates,
diagnostics,
seen,
});
}
const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
candidates,
diagnostics,
seen,
});
const bundledDir = resolveBundledPluginsDir();
if (bundledDir) {
discoverInDirectory({
dir: bundledDir,
origin: "bundled",
candidates,
diagnostics,
seen,
});
}
return { candidates, diagnostics };
}