Mirror the Composio project's auth configs as the connector catalog with logo.dev icons

This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 11:06:07 -04:00
1 parent dde449d226
commit 00e05054ca
18 files changed
+269 -175

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+4 -4
View File
@@ -329,16 +329,16 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const composioConnections = new ComposioConnectionService(deps.connection.db);
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async () => {
ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => {
try {
return ok(await composioConnections.listToolkits());
return ok(await composioConnections.listCatalog());
} catch (error) {
return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error));
return fail("composio_catalog_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
try {
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.toolkitName);
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug);
notifyDataChanged();
return ok(result);
} catch (error) {
@@ -0,0 +1,9 @@
/**
* Auth configs are configured in the Composio dashboard and mirrored through
* the API at runtime — the app no longer creates them, so the create-once
* auth_config:* cache rows in composio_settings are never read again and are
* purged. entity_id rows stay: connected-account calls still use them.
*/
export const authConfigPurgeSchema = `
delete from composio_settings where key like 'auth_config:%';
`;
+2
View File
@@ -45,6 +45,7 @@ import { composioIntegrationSchema } from "./migrations/043-composio-integration
import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state";
import { connectorGrantsSchema } from "./migrations/045-connector-grants";
import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge";
import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge";
interface Migration {
version: number;
@@ -99,6 +100,7 @@ const MIGRATIONS: Migration[] = [
{ version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema },
{ version: 45, name: "connector_grants", sql: connectorGrantsSchema },
{ version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema },
{ version: 47, name: "auth_config_purge", sql: authConfigPurgeSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+20 -41
View File
@@ -13,15 +13,11 @@ export interface ComposioToolSchema {
};
}
export interface ComposioToolkitSummary {
slug: string;
name: string;
description: string;
logo: string | null;
categories: string[];
authSchemes: string[];
noAuth: boolean;
toolsCount: number;
export interface ComposioAuthConfigSummary {
id: string;
toolkitSlug: string;
authScheme: string;
status: string;
}
export interface ComposioConnectedAccount {
@@ -40,34 +36,28 @@ export interface ComposioToolExecutionResult {
export class ComposioClient {
constructor(private readonly apiKey: string) {}
/** The toolkit catalog, most popular first. */
async listToolkits(limit: number): Promise<ComposioToolkitSummary[]> {
const toolkits: ComposioToolkitSummary[] = [];
/** Every auth config in the Composio project — the connector catalog. */
async listAuthConfigs(): Promise<ComposioAuthConfigSummary[]> {
const configs: ComposioAuthConfigSummary[] = [];
let cursor: string | null = null;
while (toolkits.length < limit) {
const params = new URLSearchParams({ limit: String(Math.min(100, limit - toolkits.length)) });
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) params.set("cursor", cursor);
const response = await this.request("GET", `${BASE_URL}/toolkits?${params.toString()}`);
const response = await this.request("GET", `${BASE_URL}/auth_configs?${params.toString()}`);
const items = Array.isArray(response.items) ? response.items as Array<Record<string, unknown>> : [];
for (const item of items) {
const meta = (item.meta ?? {}) as Record<string, unknown>;
toolkits.push({
slug: String(item.slug ?? ""),
name: String(item.name ?? ""),
description: String(meta.description ?? "").slice(0, 200),
logo: typeof meta.logo === "string" ? meta.logo : null,
categories: Array.isArray(meta.categories)
? (meta.categories as Array<Record<string, unknown>>).map((c) => String(c.name ?? c.id ?? "")).filter(Boolean)
: [],
authSchemes: Array.isArray(item.auth_schemes) ? (item.auth_schemes as unknown[]).map(String) : [],
noAuth: item.no_auth === true,
toolsCount: Number(meta.tools_count ?? 0),
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
configs.push({
id: String(item.id ?? ""),
toolkitSlug: String(toolkit.slug ?? "").toLowerCase(),
authScheme: String(item.auth_scheme ?? ""),
status: String(item.status ?? ""),
});
}
cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null;
if (!cursor || items.length === 0) break;
}
return toolkits;
if (items.length === 0) break;
} while (cursor);
return configs;
}
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
@@ -83,17 +73,6 @@ export class ComposioClient {
return all;
}
/** Creates a Composio-managed auth config for a toolkit. */
async createAuthConfig(toolkitSlug: string): Promise<string> {
const response = await this.request("POST", `${BASE_URL}/auth_configs`, {
toolkit: { slug: toolkitSlug },
});
const authConfig = (response.auth_config ?? {}) as Record<string, unknown>;
const id = String(authConfig.id ?? "");
if (!id) throw new Error("Composio auth config creation returned no id.");
return id;
}
/**
* Starts a connection for a user; the redirect URL completes it in the
* browser. Composio-managed auth configs only accept the link flow —
@@ -1,42 +1,44 @@
import type { DatabaseSync } from "node:sqlite";
import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands";
import { ComposioClient, type ComposioToolkitSummary } from "./composio-client";
import type { ConnectorCatalogEntry, ConnectComposioToolkitResult } from "../../../shared/commands";
import { ComposioClient } from "./composio-client";
import { composioApiKey } from "./composio-credentials";
import { getComposioUserId } from "./composio-tool-bridge";
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
import { normalizeSlug } from "./connector-grants";
const TOOLKIT_CATALOG_LIMIT = 200;
const CATALOG_TTL_MS = 10 * 60 * 1000;
let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | null = null;
let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null = null;
/**
* Company-level Composio connections. One connected account per toolkit per
* organization, mirrored into connector_accounts so agent runs and the
* Connectors page read local state instead of calling out on every frame.
* Company-level Composio connections. The catalog mirrors the auth configs
* of the Composio project — the dashboard is the source of truth for which
* connectors exist; the app never creates auth configs, it only connects
* accounts against them. One connected account per toolkit per organization,
* mirrored into connector_accounts so agent runs and the Connectors page
* read local state instead of calling out on every frame.
*/
export class ComposioConnectionService {
constructor(private readonly db: DatabaseSync) {}
listToolkits(): Promise<ComposioToolkitSummaryDto[]> {
const client = this.requireClient();
return this.catalog(client);
listCatalog(): Promise<ConnectorCatalogEntry[]> {
return this.catalog(this.requireClient());
}
/**
* Starts (or restarts) the company's connection for a toolkit: creates a
* Composio-managed auth config once per toolkit, initiates the connected
* account for the company user via the link flow, and records the pending
* account locally as INITIALIZING — the poll that follows adopts Composio's
* real status. The returned redirect URL completes authorization in the
* browser.
* Starts (or restarts) the company's connection for a toolkit: initiates a
* connected account against the toolkit's configured auth config via the
* link flow and records the pending account locally as INITIALIZING — the
* poll that follows adopts Composio's real status. The returned redirect
* URL completes authorization (or API-key entry) in the browser.
*/
async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise<ConnectComposioToolkitResult> {
async connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
const slug = normalizeSlug(toolkitSlug);
const client = this.requireClient();
const authConfigId = await this.ensureAuthConfig(client, orgId, slug);
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
const account = await client.createConnectedAccount({
authConfigId,
authConfigId: entry.authConfigId,
userId: getComposioUserId(this.db, orgId),
});
if (!account.id) throw new Error("Composio did not return a connected account id.");
@@ -50,7 +52,7 @@ export class ComposioConnectionService {
auth_config_id = excluded.auth_config_id,
status = excluded.status,
updated_at = excluded.updated_at
`).run(account.id, orgId, slug, toolkitName.trim() || slug, authConfigId, "INITIALIZING", now, now);
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
return { accountId: account.id, redirectUrl: account.redirectUrl };
}
@@ -73,7 +75,7 @@ export class ComposioConnectionService {
account.id,
orgId,
account.toolkitSlug,
account.toolkitSlug,
connectorDisplayName(account.toolkitSlug),
account.authConfigId,
account.status,
account.createdAt || now,
@@ -88,27 +90,24 @@ export class ComposioConnectionService {
return new ComposioClient(apiKey);
}
private async ensureAuthConfig(client: ComposioClient, orgId: string, slug: string): Promise<string> {
const key = `auth_config:${slug}`;
const existing = this.db.prepare(
"select value from composio_settings where org_id = ? and key = ? limit 1",
).get(orgId, key) as { value: string } | undefined;
if (existing?.value) return existing.value;
const authConfigId = await client.createAuthConfig(slug);
this.db.prepare(`
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
`).run(orgId, key, authConfigId, new Date().toISOString());
return authConfigId;
}
private async catalog(client: ComposioClient): Promise<ComposioToolkitSummaryDto[]> {
private async catalog(client: ComposioClient): Promise<ConnectorCatalogEntry[]> {
if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) {
return catalogCache.toolkits;
return catalogCache.entries;
}
const toolkits = await client.listToolkits(TOOLKIT_CATALOG_LIMIT);
catalogCache = { fetchedAt: Date.now(), toolkits };
return toolkits;
const configs = await client.listAuthConfigs();
const bySlug = new Map<string, ConnectorCatalogEntry>();
for (const config of configs) {
if (config.status !== "ENABLED" || !config.toolkitSlug || bySlug.has(config.toolkitSlug)) continue;
bySlug.set(config.toolkitSlug, {
authConfigId: config.id,
slug: config.toolkitSlug,
name: connectorDisplayName(config.toolkitSlug),
authScheme: config.authScheme,
logoUrl: connectorLogoUrl(config.toolkitSlug),
});
}
const entries = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
catalogCache = { fetchedAt: Date.now(), entries };
return entries;
}
}
@@ -0,0 +1,67 @@
/**
* Display names and logo domains for the toolkits configured in the Composio
* project. Logos come from img.logo.dev keyed by company domain (publishable
* token — safe to embed). A slug outside this table renders with a prettified
* name and the letter tile until its brand row is added here.
*/
const LOGO_DEV_TOKEN = "***REMOVED***";
const BRANDS: Record<string, { name: string; domain: string }> = {
airtable: { name: "Airtable", domain: "airtable.com" },
attio: { name: "Attio", domain: "attio.com" },
cal: { name: "Cal.com", domain: "cal.com" },
calendly: { name: "Calendly", domain: "calendly.com" },
canva: { name: "Canva", domain: "canva.com" },
cloudflare: { name: "Cloudflare", domain: "cloudflare.com" },
discord: { name: "Discord", domain: "discord.com" },
elevenlabs: { name: "ElevenLabs", domain: "elevenlabs.io" },
figma: { name: "Figma", domain: "figma.com" },
firecrawl: { name: "Firecrawl", domain: "firecrawl.dev" },
github: { name: "GitHub", domain: "github.com" },
gmail: { name: "Gmail", domain: "gmail.com" },
google_analytics: { name: "Google Analytics", domain: "analytics.google.com" },
googleads: { name: "Google Ads", domain: "ads.google.com" },
googlecalendar: { name: "Google Calendar", domain: "calendar.google.com" },
googledocs: { name: "Google Docs", domain: "docs.google.com" },
googledrive: { name: "Google Drive", domain: "drive.google.com" },
googlesheets: { name: "Google Sheets", domain: "sheets.google.com" },
googletasks: { name: "Google Tasks", domain: "tasks.google.com" },
heygen: { name: "HeyGen", domain: "heygen.com" },
junglescout: { name: "Jungle Scout", domain: "junglescout.com" },
linear: { name: "Linear", domain: "linear.app" },
linkedin: { name: "LinkedIn", domain: "linkedin.com" },
mailchimp: { name: "Mailchimp", domain: "mailchimp.com" },
monday: { name: "Monday", domain: "monday.com" },
notion: { name: "Notion", domain: "notion.so" },
one_drive: { name: "OneDrive", domain: "onedrive.com" },
outlook: { name: "Outlook", domain: "outlook.com" },
perplexityai: { name: "Perplexity", domain: "perplexity.ai" },
reddit: { name: "Reddit", domain: "reddit.com" },
salesforce: { name: "Salesforce", domain: "salesforce.com" },
sentry: { name: "Sentry", domain: "sentry.io" },
share_point: { name: "SharePoint", domain: "sharepoint.com" },
shopify: { name: "Shopify", domain: "shopify.com" },
slack: { name: "Slack", domain: "slack.com" },
stripe: { name: "Stripe", domain: "stripe.com" },
supabase: { name: "Supabase", domain: "supabase.com" },
tavily: { name: "Tavily", domain: "tavily.com" },
twitter: { name: "X", domain: "x.com" },
whatsapp: { name: "WhatsApp", domain: "whatsapp.com" },
zoho: { name: "Zoho", domain: "zoho.com" },
};
export function connectorDisplayName(slug: string): string {
const brand = BRANDS[slug];
if (brand) return brand.name;
return slug
.split(/[_-]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
export function connectorLogoUrl(slug: string): string | null {
const brand = BRANDS[slug];
if (!brand) return null;
return `https://img.logo.dev/${brand.domain}?token=${LOGO_DEV_TOKEN}`;
}
+1 -1
View File
@@ -83,7 +83,7 @@ const api: DesktopApi = {
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
listComposioToolkits: (input) => invoke(IPC_CHANNELS.listComposioToolkits, input),
listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog),
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' http://localhost:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://localhost:* ws://localhost:*; object-src 'none'; base-uri 'none'; frame-src 'none';"
content="default-src 'self'; script-src 'self' http://localhost:*; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://img.logo.dev; font-src 'self' data:; connect-src 'self' http://localhost:* ws://localhost:*; object-src 'none'; base-uri 'none'; frame-src 'none';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgentCompany</title>
+27 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "../shared/ui";
import type { ComposioToolkitSummaryDto } from "../../../shared/commands";
import type { ConnectorCatalogEntry } from "../../../shared/commands";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
import { ConnectedList } from "./connectors/ConnectedList";
@@ -8,7 +8,7 @@ import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
type CatalogState =
| { phase: "loading" }
| { phase: "ready"; toolkits: ComposioToolkitSummaryDto[] }
| { phase: "ready"; entries: ConnectorCatalogEntry[] }
| { phase: "error"; message: string };
type CatalogSlot = { orgId: string; state: CatalogState } | null;
@@ -47,14 +47,22 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
[accounts],
);
const logoBySlug = useMemo(() => {
const map = new Map<string, string | null>();
if (catalog.phase === "ready") {
for (const entry of catalog.entries) map.set(entry.slug, entry.logoUrl);
}
return map;
}, [catalog]);
useEffect(() => {
let cancelled = false;
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
void window.agentCompany.listConnectorCatalog().then((result) => {
if (cancelled) return;
setCatalogSlot({
orgId,
state: result.ok
? { phase: "ready", toolkits: result.data }
? { phase: "ready", entries: result.data }
: { phase: "error", message: result.error.message },
});
});
@@ -89,17 +97,17 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
}, CONNECT_POLL_INTERVAL_MS);
};
const connect = (toolkit: ComposioToolkitSummaryDto) => {
setBusySlug(toolkit.slug);
const connect = (entry: ConnectorCatalogEntry) => {
setBusySlug(entry.slug);
setConnectError(null);
void window.agentCompany
.connectComposioToolkit({ orgId, toolkitSlug: toolkit.slug, toolkitName: toolkit.name })
.connectComposioToolkit({ orgId, toolkitSlug: entry.slug })
.then(async (result) => {
if (!result.ok) {
setConnectError(result.error.message);
return;
}
pollUntilActive(toolkit.slug, 0);
pollUntilActive(entry.slug, 0);
if (result.data.redirectUrl) {
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
if (!opened.ok) setConnectError(opened.error.message);
@@ -133,11 +141,15 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
</div>
)}
<ConnectedList accounts={accounts} grants={grants} agents={agents} onGrant={grant} onRevoke={revoke} />
<ConnectedList
accounts={accounts}
grants={grants}
agents={agents}
logoBySlug={logoBySlug}
onGrant={grant}
onRevoke={revoke}
/>
{catalog.phase === "loading" && (
<div style={{ color: "var(--text-3)", fontSize: 13, padding: "24px 0" }}>Loading catalog…</div>
)}
{catalog.phase === "error" && (
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
<span
@@ -155,11 +167,11 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
</span>
<Button size="small" onClick={() => {
setCatalogSlot(null);
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
void window.agentCompany.listConnectorCatalog().then((result) => {
setCatalogSlot({
orgId,
state: result.ok
? { phase: "ready", toolkits: result.data }
? { phase: "ready", entries: result.data }
: { phase: "error", message: result.error.message },
});
});
@@ -168,7 +180,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
)}
{catalog.phase === "ready" && (
<ToolkitCatalog
toolkits={catalog.toolkits}
entries={catalog.entries}
connectedSlugs={connectedSlugs}
busySlug={busySlug}
onConnect={connect}
@@ -20,12 +20,14 @@ export function ConnectedList({
accounts,
grants,
agents,
logoBySlug,
onGrant,
onRevoke,
}: {
accounts: ConnectorAccountRecord[];
grants: ConnectorGrantRecord[];
agents: AgentRecord[];
logoBySlug: Map<string, string | null>;
onGrant: (agentId: string, toolkitSlug: string) => void;
onRevoke: (agentId: string, toolkitSlug: string) => void;
}) {
@@ -40,6 +42,7 @@ export function ConnectedList({
account={account}
grantedAgentIds={new Set(grants.filter((grant) => grant.toolkitSlug === account.toolkitSlug).map((grant) => grant.agentId))}
agents={agents}
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
onGrant={onGrant}
onRevoke={onRevoke}
/>
@@ -53,12 +56,14 @@ function ConnectedRow({
account,
grantedAgentIds,
agents,
logoUrl,
onGrant,
onRevoke,
}: {
account: ConnectorAccountRecord;
grantedAgentIds: Set<string>;
agents: AgentRecord[];
logoUrl: string | null;
onGrant: (agentId: string, toolkitSlug: string) => void;
onRevoke: (agentId: string, toolkitSlug: string) => void;
}) {
@@ -66,7 +71,7 @@ function ConnectedRow({
return (
<div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
<div className="row" style={{ gap: 10 }}>
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} size={30} />
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} logoUrl={logoUrl} size={30} />
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
</div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Button, Icon, Pill } from "../../shared/ui";
import type { ComposioToolkitSummaryDto } from "../../../../shared/commands";
import type { ConnectorCatalogEntry } from "../../../../shared/commands";
/** Deterministic tile color for a toolkit's letter glyph. */
const TILE_COLORS = ["#5e6ad2", "#3b82a0", "#7c5cb0", "#3f8f5f", "#b0684f", "#a05c86", "#6f7d3d", "#4f6fb0"];
@@ -11,7 +11,18 @@ function tileColor(slug: string): string {
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
}
export function ToolkitTile({ name, slug, size = 34 }: { name: string; slug: string; size?: number }) {
export function ToolkitTile({ name, slug, logoUrl, size = 34 }: { name: string; slug: string; logoUrl: string | null; size?: number }) {
if (logoUrl) {
return (
<img
src={logoUrl}
alt=""
width={size}
height={size}
style={{ width: size, height: size, borderRadius: 9, objectFit: "cover", flexShrink: 0 }}
/>
);
}
return (
<span
style={{
@@ -34,26 +45,22 @@ export function ToolkitTile({ name, slug, size = 34 }: { name: string; slug: str
}
export function ToolkitCatalog({
toolkits,
entries,
connectedSlugs,
busySlug,
onConnect,
}: {
toolkits: ComposioToolkitSummaryDto[];
entries: ConnectorCatalogEntry[];
connectedSlugs: Set<string>;
busySlug: string | null;
onConnect: (toolkit: ComposioToolkitSummaryDto) => void;
onConnect: (entry: ConnectorCatalogEntry) => void;
}) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return toolkits;
return toolkits.filter((toolkit) =>
toolkit.name.toLowerCase().includes(trimmed) ||
toolkit.slug.includes(trimmed) ||
toolkit.categories.some((category) => category.toLowerCase().includes(trimmed)),
);
}, [toolkits, query]);
if (!trimmed) return entries;
return entries.filter((entry) => entry.name.toLowerCase().includes(trimmed) || entry.slug.includes(trimmed));
}, [entries, query]);
return (
<section>
@@ -70,12 +77,12 @@ export function ToolkitCatalog({
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))", gap: 10 }}>
{filtered.map((toolkit) => {
const connected = connectedSlugs.has(toolkit.slug);
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 10 }}>
{filtered.map((entry) => {
const connected = connectedSlugs.has(entry.slug);
return (
<div
key={toolkit.slug}
key={entry.slug}
style={{
border: "1px solid var(--border)",
borderRadius: 12,
@@ -83,46 +90,20 @@ export function ToolkitCatalog({
background: "var(--panel)",
display: "flex",
gap: 10,
alignItems: "flex-start",
alignItems: "center",
}}
>
<ToolkitTile name={toolkit.name} slug={toolkit.slug} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: 13.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{toolkit.name}
</span>
{toolkit.categories[0] && <Pill>{toolkit.categories[0]}</Pill>}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
lineHeight: 1.45,
marginTop: 2,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
minHeight: 34,
}}
>
{toolkit.description}
</div>
<div className="row" style={{ marginTop: 8 }}>
{connected ? (
<Pill tone="green" dot>Connected</Pill>
) : (
<Button
size="small"
disabled={busySlug !== null}
onClick={() => onConnect(toolkit)}
>
{busySlug === toolkit.slug ? "Opening…" : "Connect"}
</Button>
)}
</div>
</div>
<ToolkitTile name={entry.name} slug={entry.slug} logoUrl={entry.logoUrl} />
<span style={{ fontWeight: 600, fontSize: 13.5, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{entry.name}
</span>
{connected ? (
<Pill tone="green" dot>Connected</Pill>
) : (
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry)}>
{busySlug === entry.slug ? "Opening…" : "Connect"}
</Button>
)}
</div>
);
})}
+4 -12
View File
@@ -1,22 +1,14 @@
export interface ComposioToolkitSummaryDto {
export interface ConnectorCatalogEntry {
authConfigId: string;
slug: string;
name: string;
description: string;
logo: string | null;
categories: string[];
authSchemes: string[];
noAuth: boolean;
toolsCount: number;
}
export interface ListComposioToolkitsInput {
orgId: string;
authScheme: string;
logoUrl: string | null;
}
export interface ConnectComposioToolkitInput {
orgId: string;
toolkitSlug: string;
toolkitName: string;
}
export interface ConnectComposioToolkitResult {
+2 -3
View File
@@ -64,11 +64,10 @@ import type {
SyncSkillsFromDiskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
ComposioToolkitSummaryDto,
ConnectComposioToolkitInput,
ConnectComposioToolkitResult,
ConnectorCatalogEntry,
ConnectorGrantInput,
ListComposioToolkitsInput,
RefreshComposioAccountsInput,
SearchInput,
SendMessageInput,
@@ -180,7 +179,7 @@ export interface DesktopApi {
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
listComposioToolkits(input: ListComposioToolkitsInput): Promise<DesktopResult<ComposioToolkitSummaryDto[]>>;
listConnectorCatalog(): Promise<DesktopResult<ConnectorCatalogEntry[]>>;
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
grantConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
+1 -1
View File
@@ -75,7 +75,7 @@ export const IPC_CHANNELS = {
deleteMeeting: "agentcompany:delete-meeting",
createSprint: "agentcompany:create-sprint",
deleteSprint: "agentcompany:delete-sprint",
listComposioToolkits: "agentcompany:list-composio-toolkits",
listConnectorCatalog: "agentcompany:list-connector-catalog",
connectComposioToolkit: "agentcompany:connect-composio-toolkit",
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
grantConnector: "agentcompany:grant-connector",
@@ -0,0 +1,31 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { authConfigPurgeSchema } from "../../src/main/database/migrations/047-auth-config-purge";
import { OrganizationService } from "../../src/main/services/organization-service";
describe("auth config purge migration", () => {
test("removes cached auth_config rows but keeps the entity id", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-authconfig-purge-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
});
const now = new Date().toISOString();
const insert = connection.db.prepare(
"insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)",
);
insert.run(orgId, "entity_id", orgId, now);
insert.run(orgId, "auth_config:github", "ac_123", now);
insert.run(orgId, "auth_config:stripe", "ac_456", now);
connection.db.exec(authConfigPurgeSchema);
const keys = (connection.db.prepare("select key from composio_settings where org_id = ? order by key asc").all(orgId) as Array<{ key: string }>)
.map((row) => row.key);
expect(keys).toEqual(["entity_id"]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { connectorDisplayName, connectorLogoUrl } from "../../src/main/services/composio/connector-brands";
describe("connector brands", () => {
test("known toolkits resolve a display name and a logo.dev URL", () => {
expect(connectorDisplayName("google_analytics")).toBe("Google Analytics");
expect(connectorLogoUrl("google_analytics")).toBe(
"https://img.logo.dev/analytics.google.com?token=***REMOVED***",
);
expect(connectorDisplayName("share_point")).toBe("SharePoint");
expect(connectorLogoUrl("stripe")).toContain("img.logo.dev/stripe.com");
});
test("unknown toolkits prettify the slug and carry no logo", () => {
expect(connectorDisplayName("acme_billing")).toBe("Acme Billing");
expect(connectorLogoUrl("acme_billing")).toBeNull();
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]);
connection.close();
});