refactor: plugin catalog + nextcloud policy

This commit is contained in:
Peter Steinberger
2026-01-20 11:11:42 +00:00
parent 9ec1fb4a80
commit 660f87278c
33 changed files with 2865 additions and 213 deletions

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { ClawdbotManifest, PackageManifest } from "./manifest.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
@@ -16,6 +17,8 @@ export type PluginCandidate = {
packageName?: string;
packageVersion?: string;
packageDescription?: string;
packageDir?: string;
packageClawdbot?: ClawdbotManifest;
};
export type PluginDiscoveryResult = {
@@ -23,15 +26,6 @@ export type PluginDiscoveryResult = {
diagnostics: PluginDiagnostic[];
};
type PackageManifest = {
name?: string;
version?: string;
description?: string;
clawdbot?: {
extensions?: string[];
};
};
function isExtensionFile(filePath: string): boolean {
const ext = path.extname(filePath);
if (!EXTENSION_EXTS.has(ext)) return false;
@@ -83,6 +77,7 @@ function addCandidate(params: {
origin: PluginOrigin;
workspaceDir?: string;
manifest?: PackageManifest | null;
packageDir?: string;
}) {
const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) return;
@@ -97,6 +92,8 @@ function addCandidate(params: {
packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined,
packageDescription: manifest?.description?.trim() || undefined,
packageDir: params.packageDir,
packageClawdbot: manifest?.clawdbot,
});
}
@@ -156,6 +153,7 @@ function discoverInDirectory(params: {
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
continue;
@@ -174,6 +172,8 @@ function discoverInDirectory(params: {
rootDir: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
}
@@ -239,6 +239,7 @@ function discoverFromPath(params: {
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
}
return;
@@ -258,6 +259,8 @@ function discoverFromPath(params: {
rootDir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
return;
}

View File

@@ -1,91 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import type { PluginConfigUiHint, PluginKind } from "./types.js";
export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json";
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
kind?: PluginKind;
channels?: string[];
providers?: string[];
name?: string;
description?: string;
version?: string;
uiHints?: Record<string, PluginConfigUiHint>;
export type PluginManifestChannel = {
id?: string;
label?: string;
selectionLabel?: string;
docsPath?: string;
docsLabel?: string;
blurb?: string;
order?: number;
aliases?: string[];
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
};
export type PluginManifestLoadResult =
| { ok: true; manifest: PluginManifest; manifestPath: string }
| { ok: false; error: string; manifestPath: string };
export type PluginManifestInstall = {
npmSpec?: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
export type ClawdbotManifest = {
extensions?: string[];
channel?: PluginManifestChannel;
install?: PluginManifestInstall;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
const manifestPath = resolvePluginManifestPath(rootDir);
if (!fs.existsSync(manifestPath)) {
return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath };
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
}
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}
const id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
return { ok: false, error: "plugin manifest requires id", manifestPath };
}
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
if (!configSchema) {
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
return {
ok: true,
manifest: {
id,
configSchema,
kind,
channels,
providers,
name,
description,
version,
uiHints,
},
manifestPath,
};
}
export type PackageManifest = {
name?: string;
version?: string;
description?: string;
clawdbot?: ClawdbotManifest;
};

View File

@@ -52,6 +52,7 @@ import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
@@ -177,6 +178,10 @@ export function createPluginRuntime(): PluginRuntime {
fetchRemoteMedia,
saveMediaBuffer,
},
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: {
resolveStorePath,
readSessionUpdatedAt,

View File

@@ -55,6 +55,8 @@ type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessio
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
type LoadConfig = typeof import("../../config/config.js").loadConfig;
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
type RecordChannelActivity = typeof import("../../infra/channel-activity.js").recordChannelActivity;
type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity;
type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent;
type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
@@ -188,6 +190,10 @@ export type PluginRuntime = {
fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer;
};
activity: {
record: RecordChannelActivity;
get: GetChannelActivity;
};
session: {
resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt;