feat: add tlon channel plugin

This commit is contained in:
Peter Steinberger
2026-01-24 00:17:58 +00:00
parent d46642319b
commit 791b568f78
38 changed files with 2431 additions and 3027 deletions

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
@@ -13,4 +16,37 @@ describe("channel plugin catalog", () => {
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
expect(ids).toContain("msteams");
});
it("includes external catalog entries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-catalog-"));
const catalogPath = path.join(dir, "catalog.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@clawdbot/demo-channel",
clawdbot: {
channel: {
id: "demo-channel",
label: "Demo Channel",
selectionLabel: "Demo Channel",
docsPath: "/channels/demo-channel",
blurb: "Demo entry",
order: 999,
},
install: {
npmSpec: "@clawdbot/demo-channel",
},
},
},
],
}),
);
const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map(
(entry) => entry.id,
);
expect(ids).toContain("demo-channel");
});
});

View File

@@ -1,8 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
import type { PluginOrigin } from "../../plugins/types.js";
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import type { ChannelMeta } from "./types.js";
export type ChannelUiMetaEntry = {
@@ -33,6 +35,7 @@ export type ChannelPluginCatalogEntry = {
type CatalogOptions = {
workspaceDir?: string;
catalogPaths?: string[];
};
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
@@ -42,6 +45,74 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
bundled: 3,
};
type ExternalCatalogEntry = {
name?: string;
version?: string;
description?: string;
clawdbot?: ClawdbotPackageManifest;
};
const DEFAULT_CATALOG_PATHS = [
path.join(CONFIG_DIR, "mpm", "plugins.json"),
path.join(CONFIG_DIR, "mpm", "catalog.json"),
path.join(CONFIG_DIR, "plugins", "catalog.json"),
];
const ENV_CATALOG_PATHS = ["CLAWDBOT_PLUGIN_CATALOG_PATHS", "CLAWDBOT_MPM_CATALOG_PATHS"];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] {
if (Array.isArray(raw)) {
return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry));
}
if (!isRecord(raw)) return [];
const list = raw.entries ?? raw.packages ?? raw.plugins;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry));
}
function splitEnvPaths(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
return trimmed
.split(/[;,]/g)
.flatMap((chunk) => chunk.split(path.delimiter))
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolveExternalCatalogPaths(options: CatalogOptions): string[] {
if (options.catalogPaths && options.catalogPaths.length > 0) {
return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean);
}
for (const key of ENV_CATALOG_PATHS) {
const raw = process.env[key];
if (raw && raw.trim()) {
return splitEnvPaths(raw);
}
}
return DEFAULT_CATALOG_PATHS;
}
function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] {
const paths = resolveExternalCatalogPaths(options);
const entries: ExternalCatalogEntry[] = [];
for (const rawPath of paths) {
const resolved = resolveUserPath(rawPath);
if (!fs.existsSync(resolved)) continue;
try {
const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown;
entries.push(...parseCatalogEntries(payload));
} catch {
// Ignore invalid catalog files.
}
}
return entries;
}
function toChannelMeta(params: {
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
id: string;
@@ -132,6 +203,13 @@ function buildCatalogEntry(candidate: {
return { id, meta, install };
}
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
return buildCatalogEntry({
packageName: entry.name,
packageClawdbot: entry.clawdbot,
});
}
export function buildChannelUiCatalog(
plugins: Array<{ id: string; meta: ChannelMeta }>,
): ChannelUiCatalog {
@@ -176,6 +254,15 @@ export function listChannelPluginCatalogEntries(
}
}
const externalEntries = loadExternalCatalogEntries(options)
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
for (const entry of externalEntries) {
if (!resolved.has(entry.id)) {
resolved.set(entry.id, { entry, priority: 99 });
}
}
return Array.from(resolved.values())
.map(({ entry }) => entry)
.sort((a, b) => {

View File

@@ -39,6 +39,12 @@ export type ChannelSetupInput = {
password?: string;
deviceName?: string;
initialSyncLimit?: number;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
export type ChannelStatusIssue = {