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 = {

View File

@@ -1,14 +1,29 @@
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
function dedupe(values: string[]): string[] {
const seen = new Set<string>();
const resolved: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
resolved.push(value);
}
return resolved;
}
export function resolveCliChannelOptions(): string[] {
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) {
ensurePluginRegistryLoaded();
return listChannelPlugins().map((plugin) => plugin.id);
const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
return dedupe([...base, ...pluginIds]);
}
return [...CHAT_CHANNEL_ORDER];
return base;
}
export function formatCliChannelOptions(extra: string[] = []): string {

View File

@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliChannelOptions } from "./channel-options.js";
import {
channelsAddCommand,
channelsCapabilitiesCommand,
@@ -42,6 +42,12 @@ const optionNamesAdd = [
"password",
"deviceName",
"initialSyncLimit",
"ship",
"url",
"code",
"groupChannels",
"dmAllowlist",
"autoDiscoverChannels",
] as const;
const optionNamesRemove = ["channel", "account", "delete"] as const;
@@ -58,9 +64,7 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
}
export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channelNames = formatCliChannelOptions();
const channels = program
.command("channels")
.description("Manage chat channel accounts")
@@ -99,7 +103,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("capabilities")
.description("Show provider capabilities (intents/scopes + supported features)")
.option("--channel <name>", `Channel (${channelNames}|all)`)
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`)
.option("--account <id>", "Account id (only with --channel)")
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
.option("--timeout <ms>", "Timeout in ms", "10000")
@@ -136,7 +140,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("logs")
.description("Show recent channel logs from the gateway log file")
.option("--channel <name>", `Channel (${channelNames}|all)`, "all")
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`, "all")
.option("--lines <n>", "Number of lines (default: 200)", "200")
.option("--json", "Output JSON", false)
.action(async (opts) => {
@@ -171,6 +175,13 @@ export function registerChannelsCli(program: Command) {
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--ship <ship>", "Tlon ship name (~sampel-palnet)")
.option("--url <url>", "Tlon ship URL")
.option("--code <code>", "Tlon login code")
.option("--group-channels <list>", "Tlon group channels (comma-separated)")
.option("--dm-allowlist <list>", "Tlon DM allowlist (comma-separated ships)")
.option("--auto-discover-channels", "Tlon auto-discover group channels")
.option("--no-auto-discover-channels", "Disable Tlon auto-discovery")
.option("--use-env", "Use env token (default account only)", false)
.action(async (opts, command) => {
await runChannelsCommand(async () => {

View File

@@ -43,6 +43,12 @@ export function applyChannelAccountConfig(params: {
password?: string;
deviceName?: string;
initialSyncLimit?: number;
ship?: string;
url?: string;
code?: string;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
@@ -71,6 +77,12 @@ export function applyChannelAccountConfig(params: {
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: params.initialSyncLimit,
ship: params.ship,
url: params.url,
code: params.code,
groupChannels: params.groupChannels,
dmAllowlist: params.dmAllowlist,
autoDiscoverChannels: params.autoDiscoverChannels,
};
return apply({ cfg: params.cfg, accountId, input });
}

View File

@@ -1,11 +1,17 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { writeConfigFile } from "../../config/config.js";
import { writeConfigFile, type ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "../onboarding/plugin-install.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
@@ -34,8 +40,33 @@ export type ChannelsAddOptions = {
password?: string;
deviceName?: string;
initialSyncLimit?: number | string;
ship?: string;
url?: string;
code?: string;
groupChannels?: string;
dmAllowlist?: string;
autoDiscoverChannels?: boolean;
};
function parseList(value: string | undefined): string[] | undefined {
if (!value?.trim()) return undefined;
const parsed = value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return parsed.length > 0 ? parsed : undefined;
}
function resolveCatalogChannelEntry(raw: string, cfg: ClawdbotConfig | null) {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) return undefined;
const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined;
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
if (entry.id.toLowerCase() === trimmed) return true;
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
});
}
export async function channelsAddCommand(
opts: ChannelsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -43,6 +74,7 @@ export async function channelsAddCommand(
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
let nextConfig = cfg;
const useWizard = shouldUseWizard(params);
if (useWizard) {
@@ -99,9 +131,31 @@ export async function channelsAddCommand(
return;
}
const channel = normalizeChannelId(opts.channel);
const rawChannel = String(opts.channel ?? "");
let channel = normalizeChannelId(rawChannel);
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
if (!channel && catalogEntry) {
const prompter = createClackPrompter();
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
const result = await ensureOnboardingPluginInstalled({
cfg: nextConfig,
entry: catalogEntry,
prompter,
runtime,
workspaceDir,
});
nextConfig = result.cfg;
if (!result.installed) return;
reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir });
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
}
if (!channel) {
runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`);
const hint = catalogEntry
? `Plugin ${catalogEntry.meta.label} could not be loaded after install.`
: `Unknown channel: ${String(opts.channel ?? "")}`;
runtime.error(hint);
runtime.exit(1);
return;
}
@@ -113,7 +167,7 @@ export async function channelsAddCommand(
return;
}
const accountId =
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
const initialSyncLimit =
@@ -122,8 +176,11 @@ export async function channelsAddCommand(
: typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim()
? Number.parseInt(opts.initialSyncLimit, 10)
: undefined;
const groupChannels = parseList(opts.groupChannels);
const dmAllowlist = parseList(opts.dmAllowlist);
const validationError = plugin.setup.validateInput?.({
cfg,
cfg: nextConfig,
accountId,
input: {
name: opts.name,
@@ -148,6 +205,12 @@ export async function channelsAddCommand(
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
ship: opts.ship,
url: opts.url,
code: opts.code,
groupChannels,
dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels,
},
});
if (validationError) {
@@ -156,8 +219,8 @@ export async function channelsAddCommand(
return;
}
const nextConfig = applyChannelAccountConfig({
cfg,
nextConfig = applyChannelAccountConfig({
cfg: nextConfig,
channel,
accountId,
name: opts.name,
@@ -182,6 +245,12 @@ export async function channelsAddCommand(
deviceName: opts.deviceName,
initialSyncLimit,
useEnv,
ship: opts.ship,
url: opts.url,
code: opts.code,
groupChannels,
dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels,
});
await writeConfigFile(nextConfig);

View File

@@ -111,7 +111,8 @@ async function collectChannelStatus(params: {
}): Promise<ChannelStatusSummary> {
const installedPlugins = listChannelPlugins();
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
const catalogEntries = listChannelPluginCatalogEntries().filter(
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter(
(entry) => !installedIds.has(entry.id),
);
const statusEntries = await Promise.all(
@@ -388,7 +389,8 @@ export async function setupChannels(
const core = listChatChannels();
const installed = listChannelPlugins();
const installedIds = new Set(installed.map((plugin) => plugin.id));
const catalog = listChannelPluginCatalogEntries().filter(
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter(
(entry) => !installedIds.has(entry.id),
);
const metaById = new Map<string, ChannelMeta>();