From aa2a830f237ae67b53ed30c8f5b6b38ae9590c64 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 2 Feb 2026 21:34:47 +0530 Subject: [PATCH] feat: slack integration with composio Allow users to ask copilot to use Slack on their behalf via Composio integration. Adds composio client, OAuth flow, slack skill with tool catalog, and UI for connecting Slack in onboarding and connectors popover. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/auth-server.ts | 78 ++-- apps/x/apps/main/src/composio-handler.ts | 296 ++++++++++++++ apps/x/apps/main/src/ipc.ts | 28 +- .../src/components/composio-api-key-modal.tsx | 94 +++++ .../src/components/connectors-popover.tsx | 171 +++++++- .../src/components/onboarding-modal.tsx | 158 ++++++- apps/x/packages/core/package.json | 1 + .../src/application/assistant/instructions.ts | 3 + .../src/application/assistant/skills/index.ts | 44 +- .../assistant/skills/slack/skill.ts | 121 ++++++ .../assistant/skills/slack/tool-catalog.ts | 117 ++++++ .../core/src/application/lib/builtin-tools.ts | 387 ++++++++++++++++++ apps/x/packages/core/src/composio/client.ts | 358 ++++++++++++++++ apps/x/packages/core/src/composio/index.ts | 5 + apps/x/packages/core/src/composio/repo.ts | 140 +++++++ apps/x/packages/core/src/composio/types.ts | 237 +++++++++++ apps/x/packages/shared/src/ipc.ts | 81 +++- apps/x/pnpm-lock.yaml | 71 ++++ 18 files changed, 2309 insertions(+), 81 deletions(-) create mode 100644 apps/x/apps/main/src/composio-handler.ts create mode 100644 apps/x/apps/renderer/src/components/composio-api-key-modal.tsx create mode 100644 apps/x/packages/core/src/application/assistant/skills/slack/skill.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts create mode 100644 apps/x/packages/core/src/composio/client.ts create mode 100644 apps/x/packages/core/src/composio/index.ts create mode 100644 apps/x/packages/core/src/composio/repo.ts create mode 100644 apps/x/packages/core/src/composio/types.ts diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 8309e3e6..b0b890c0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -4,6 +4,16 @@ import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; const DEFAULT_PORT = 8080; +/** Escape HTML special characters to prevent XSS */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export interface AuthServerResult { server: Server; port: number; @@ -15,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void + onCallback: (code: string, state: string) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -46,7 +56,7 @@ export function createAuthServer(

Authorization Failed

-

Error: ${error}

+

Error: ${escapeHtml(error)}

You can close this window.

@@ -55,48 +65,28 @@ export function createAuthServer( return; } - if (code && state) { - onCallback(code, state); - - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - Authorization Successful - - - -

Authorization Successful

-

You can close this window.

- - - - `); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - - - OAuth Error - - - -

Invalid Request

-

Missing code or state parameter.

-

You can close this window.

- - - - `); - } + // Handle callback - either traditional OAuth with code/state or Composio-style notification + // Composio callbacks may not have code/state, just a notification that the flow completed + onCallback(code || '', state || ''); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + Authorization Successful + + + +

Authorization Successful

+

You can close this window.

+ + + + `); } else { res.writeHead(404); res.end('Not Found'); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts new file mode 100644 index 00000000..e5b25d1a --- /dev/null +++ b/apps/x/apps/main/src/composio-handler.ts @@ -0,0 +1,296 @@ +import { shell, BrowserWindow } from 'electron'; +import { createAuthServer } from './auth-server.js'; +import * as composioClient from '@x/core/dist/composio/client.js'; +import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; +import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js'; + +const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; + +// Store active OAuth flows +const activeFlows = new Map(); + +/** + * Emit Composio connection event to all renderer windows + */ +export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('composio:didConnect', event); + } + } +} + +/** + * Check if Composio is configured with an API key + */ +export function isConfigured(): { configured: boolean } { + return { configured: composioClient.isConfigured() }; +} + +/** + * Set the Composio API key + */ +export function setApiKey(apiKey: string): { success: boolean; error?: string } { + try { + composioClient.setApiKey(apiKey); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set API key', + }; + } +} + +/** + * Initiate OAuth connection for a toolkit + */ +export async function initiateConnection(toolkitSlug: string): Promise<{ + success: boolean; + redirectUrl?: string; + connectedAccountId?: string; + error?: string; +}> { + try { + console.log(`[Composio] Initiating connection for ${toolkitSlug}...`); + + // Check if already connected + if (composioAccountsRepo.isConnected(toolkitSlug)) { + return { success: true }; + } + + // Get toolkit to check auth schemes + const toolkit = await composioClient.getToolkit(toolkitSlug); + + // Check for managed OAuth2 + if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) { + return { + success: false, + error: `Toolkit ${toolkitSlug} does not support managed OAuth2`, + }; + } + + // Find or create managed OAuth2 auth config + const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true); + let authConfigId: string; + + const managedOauth2 = authConfigs.items.find( + cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed + ); + + if (managedOauth2) { + authConfigId = managedOauth2.id; + } else { + // Create new managed auth config + const created = await composioClient.createAuthConfig({ + toolkit: { slug: toolkitSlug }, + auth_config: { + type: 'use_composio_managed_auth', + name: `rowboat-${toolkitSlug}`, + }, + }); + authConfigId = created.auth_config.id; + } + + // Create connected account with callback URL + const callbackUrl = REDIRECT_URI; + const response = await composioClient.createConnectedAccount({ + auth_config: { id: authConfigId }, + connection: { + user_id: 'rowboat-user', + callback_url: callbackUrl, + }, + }); + + const connectedAccountId = response.id; + + // Safely extract redirectUrl with type checking + const connectionVal = response.connectionData?.val; + const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal + ? String((connectionVal as Record).redirectUrl) + : undefined; + + if (!redirectUrl) { + return { + success: false, + error: 'No redirect URL received from Composio', + }; + } + + // Store flow state + const flowKey = `${toolkitSlug}-${Date.now()}`; + activeFlows.set(flowKey, { + toolkitSlug, + connectedAccountId, + authConfigId, + }); + + // Save initial account state + const account: LocalConnectedAccount = { + id: connectedAccountId, + authConfigId, + status: 'INITIATED', + toolkitSlug, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + }; + composioAccountsRepo.saveAccount(account); + + // Set up callback server + let cleanupTimeout: NodeJS.Timeout; + const { server } = await createAuthServer(8081, async (_code, _state) => { + // OAuth callback received - sync the account status + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + + if (accountStatus.status === 'ACTIVE') { + emitComposioEvent({ toolkitSlug, success: true }); + } else { + emitComposioEvent({ + toolkitSlug, + success: false, + error: `Connection status: ${accountStatus.status}`, + }); + } + } catch (error) { + console.error('[Composio] Failed to sync account status:', error); + emitComposioEvent({ + toolkitSlug, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + activeFlows.delete(flowKey); + server.close(); + clearTimeout(cleanupTimeout); + } + }); + + // Timeout for abandoned flows (5 minutes) + cleanupTimeout = setTimeout(() => { + if (activeFlows.has(flowKey)) { + console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); + activeFlows.delete(flowKey); + server.close(); + emitComposioEvent({ + toolkitSlug, + success: false, + error: 'OAuth flow timed out', + }); + } + }, 5 * 60 * 1000); + + // Open browser for OAuth + shell.openExternal(redirectUrl); + + return { + success: true, + redirectUrl, + connectedAccountId, + }; + } catch (error) { + console.error('[Composio] Connection initiation failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Get connection status for a toolkit + */ +export async function getConnectionStatus(toolkitSlug: string): Promise<{ + isConnected: boolean; + status?: string; +}> { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account) { + return { isConnected: false }; + } + return { + isConnected: account.status === 'ACTIVE', + status: account.status, + }; +} + +/** + * Sync connection status with Composio API + */ +export async function syncConnection( + toolkitSlug: string, + connectedAccountId: string +): Promise<{ status: string }> { + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + return { status: accountStatus.status }; + } catch (error) { + console.error('[Composio] Failed to sync connection:', error); + return { status: 'FAILED' }; + } +} + +/** + * Disconnect a toolkit + */ +export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (account) { + // Delete from Composio + await composioClient.deleteConnectedAccount(account.id); + // Delete local record + composioAccountsRepo.deleteAccount(toolkitSlug); + } + return { success: true }; + } catch (error) { + console.error('[Composio] Disconnect failed:', error); + // Still delete local record even if API call fails + composioAccountsRepo.deleteAccount(toolkitSlug); + return { success: true }; + } +} + +/** + * List connected toolkits + */ +export function listConnected(): { toolkits: string[] } { + return { toolkits: composioAccountsRepo.getConnectedToolkits() }; +} + +/** + * Execute a Composio action + */ +export async function executeAction( + actionSlug: string, + toolkitSlug: string, + input: Record +): Promise<{ success: boolean; data: unknown; error?: string }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { + success: false, + data: null, + error: `Toolkit ${toolkitSlug} is not connected`, + }; + } + + const result = await composioClient.executeAction(actionSlug, account.id, input); + return result; + } catch (error) { + console.error('[Composio] Action execution failed:', error); + return { + success: false, + data: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 03253c72..42690bda 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -17,11 +17,12 @@ import { bus } from '@x/core/dist/runs/bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; import z from 'zod'; -import { RunEvent } from 'packages/shared/dist/runs.js'; +import { RunEvent } from '@x/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import * as composioHandler from './composio-handler.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -364,5 +365,30 @@ export function setupIpcHandlers() { 'auth:logout': async () => { return await logoutRowboat(); }, + // Composio integration handlers + 'composio:is-configured': async () => { + return composioHandler.isConfigured(); + }, + 'composio:set-api-key': async (_event, args) => { + return composioHandler.setApiKey(args.apiKey); + }, + 'composio:initiate-connection': async (_event, args) => { + return composioHandler.initiateConnection(args.toolkitSlug); + }, + 'composio:get-connection-status': async (_event, args) => { + return composioHandler.getConnectionStatus(args.toolkitSlug); + }, + 'composio:sync-connection': async (_event, args) => { + return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId); + }, + 'composio:disconnect': async (_event, args) => { + return composioHandler.disconnect(args.toolkitSlug); + }, + 'composio:list-connected': async () => { + return composioHandler.listConnected(); + }, + 'composio:execute-action': async (_event, args) => { + return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + }, }); } \ No newline at end of file diff --git a/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx new file mode 100644 index 00000000..826fe04f --- /dev/null +++ b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +interface ComposioApiKeyModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (apiKey: string) => void + isSubmitting?: boolean +} + +export function ComposioApiKeyModal({ + open, + onOpenChange, + onSubmit, + isSubmitting = false, +}: ComposioApiKeyModalProps) { + const [apiKey, setApiKey] = useState("") + + useEffect(() => { + if (!open) { + setApiKey("") + } + }, [open]) + + const trimmedApiKey = apiKey.trim() + const isValid = trimmedApiKey.length > 0 + + const handleSubmit = () => { + if (!isValid || isSubmitting) return + onSubmit(trimmedApiKey) + } + + return ( + + + + Enter Composio API Key + + Get your API key from{" "} + + app.composio.dev/settings + + + +
+ + setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ced6064f..1799ab75 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail } from "lucide-react" +import { Loader2, Mic, Mail, MessageSquare } from "lucide-react" import { Popover, @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { toast } from "sonner" interface ProviderState { @@ -40,6 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + // Load available providers on mount useEffect(() => { async function loadProviders() { @@ -86,11 +93,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Connect to Slack via Composio + const startSlackConnect = useCallback(async () => { + try { + setSlackConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Slack') + setSlackConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Slack:', error) + toast.error('Failed to connect to Slack') + setSlackConnecting(false) + } + }, []) + + // Handle Slack connect button click + const handleConnectSlack = useCallback(async () => { + // Check if Composio is configured + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyOpen(true) + return + } + await startSlackConnect() + }, [startSlackConnect]) + + // Handle Composio API key submission + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + // Now start the Slack connection + await startSlackConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startSlackConnect]) + + // Disconnect from Slack + const handleDisconnectSlack = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' }) + if (result.success) { + setSlackConnected(false) + toast.success('Disconnected from Slack') + } else { + toast.error('Failed to disconnect from Slack') + } + } catch (error) { + console.error('Failed to disconnect from Slack:', error) + toast.error('Failed to disconnect from Slack') + } finally { + setSlackLoading(false) + } + }, []) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -117,7 +202,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -161,6 +246,26 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, [refreshAllStatuses]) + // Listen for Composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'slack') { + setSlackConnected(success) + setSlackConnecting(false) + + if (success) { + toast.success('Connected to Slack') + } else { + toast.error(error || 'Failed to connect to Slack') + } + } + }) + + return cleanup + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -289,6 +394,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } return ( + <> {tooltip ? ( @@ -368,10 +474,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {/* Fireflies */} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section - Slack */} +
+ Team Communication +
+ + {/* Slack */} +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( + + ) : ( + + )} +
+
)}
+ + ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 48119934..074ad645 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react" import { Dialog, @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { toast } from "sonner" interface ProviderState { @@ -41,6 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + // Track connected providers for the completion step const connectedProviders = Object.entries(providerStates) .filter(([, state]) => state.isConnected) @@ -94,11 +101,70 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Start Slack connection + const startSlackConnect = useCallback(async () => { + try { + setSlackConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Slack') + setSlackConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Slack:', error) + toast.error('Failed to connect to Slack') + setSlackConnecting(false) + } + }, []) + + // Connect to Slack via Composio (checks if configured first) + const handleConnectSlack = useCallback(async () => { + // Check if Composio is configured + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyOpen(true) + return + } + await startSlackConnect() + }, [startSlackConnect]) + + // Handle Composio API key submission + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + // Now start the Slack connection + await startSlackConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startSlackConnect]) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -125,7 +191,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -159,6 +225,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) + // Listen for Composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'slack') { + setSlackConnected(success) + setSlackConnecting(false) + + if (success) { + toast.success('Connected to Slack') + } else { + toast.error(error || 'Failed to connect to Slack') + } + } + }) + + return cleanup + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -291,6 +377,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) + // Render Slack row + const renderSlackRow = () => ( +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Step 0: Welcome const WelcomeStep = () => (
@@ -358,6 +488,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {renderGranolaRow()} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ + {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)} @@ -375,7 +513,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected return (
@@ -416,6 +554,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} + {slackConnected && ( +
+ + Slack (Team communication) +
+ )} @@ -429,6 +573,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } return ( + <> + {}}> } + ) } diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 2633e4d0..d30c199c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,6 +12,7 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", + "@composio/core": "^0.1.48", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", "@google-cloud/local-auth": "^3.0.1", diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 3785c493..57f3a446 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -30,6 +30,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base. +**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending. + ## Memory That Compounds Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic. @@ -159,6 +161,7 @@ When a user asks for ANY task that might require external capabilities (web sear - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading +- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 391f9523..6ef19e8d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -7,18 +7,17 @@ import draftEmailsSkill from "./draft-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; +import slackSkill from "./slack/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; -const CURRENT_FILE = fileURLToPath(import.meta.url); -const CURRENT_DIR = path.dirname(CURRENT_FILE); +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; type SkillDefinition = { - id: string; + id: string; // Also used as folder name title: string; - folder: string; summary: string; content: string; }; @@ -33,70 +32,66 @@ const definitions: SkillDefinition[] = [ { id: "create-presentations", title: "Create Presentations", - folder: "create-presentations", summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.", content: createPresentationsSkill, }, { id: "doc-collab", title: "Document Collaboration", - folder: "doc-collab", summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.", content: docCollabSkill, }, { id: "draft-emails", title: "Draft Emails", - folder: "draft-emails", summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.", content: draftEmailsSkill, }, { id: "meeting-prep", title: "Meeting Prep", - folder: "meeting-prep", summary: "Prepare for meetings by gathering context about attendees from the knowledge base.", content: meetingPrepSkill, }, { id: "organize-files", title: "Organize Files", - folder: "organize-files", summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.", content: organizeFilesSkill, }, + { + id: "slack", + title: "Slack Integration", + summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.", + content: slackSkill, + }, { id: "workflow-authoring", title: "Workflow Authoring", - folder: "workflow-authoring", summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", content: workflowAuthoringSkill, }, { id: "builtin-tools", title: "Builtin Tools Reference", - folder: "builtin-tools", summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.", content: builtinToolsSkill, }, { id: "mcp-integration", title: "MCP Integration Guidance", - folder: "mcp-integration", summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.", content: mcpIntegrationSkill, }, { id: "deletion-guardrails", title: "Deletion Guardrails", - folder: "deletion-guardrails", summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, { id: "workflow-run-ops", title: "Workflow Run Operations", - folder: "workflow-run-ops", summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", content: workflowRunOpsSkill, }, @@ -104,7 +99,7 @@ const definitions: SkillDefinition[] = [ const skillEntries = definitions.map((definition) => ({ ...definition, - catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`, + catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`, })); const catalogSections = skillEntries.map((entry) => [ @@ -154,8 +149,8 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { }; for (const entry of skillEntries) { - const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts"); - const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js"); + const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts"); + const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js"); const resolvedEntry: ResolvedSkill = { id: entry.id, catalogPath: entry.catalogPath, @@ -164,14 +159,13 @@ for (const entry of skillEntries) { const baseAliases = [ entry.id, - entry.folder, - `${entry.folder}/skill`, - `${entry.folder}/skill.ts`, - `${entry.folder}/skill.js`, - `skills/${entry.folder}/skill.ts`, - `skills/${entry.folder}/skill.js`, - `${CATALOG_PREFIX}/${entry.folder}/skill.ts`, - `${CATALOG_PREFIX}/${entry.folder}/skill.js`, + `${entry.id}/skill`, + `${entry.id}/skill.ts`, + `${entry.id}/skill.js`, + `skills/${entry.id}/skill.ts`, + `skills/${entry.id}/skill.js`, + `${CATALOG_PREFIX}/${entry.id}/skill.ts`, + `${CATALOG_PREFIX}/${entry.id}/skill.js`, absoluteTs, absoluteJs, ]; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts new file mode 100644 index 00000000..140c6ab7 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -0,0 +1,121 @@ +import { slackToolCatalogMarkdown } from "./tool-catalog.js"; + +const skill = String.raw` +# Slack Integration Skill + +You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations. + +## Prerequisites + +Before using Slack tools, ALWAYS check if Slack is connected: +\`\`\` +slack-checkConnection({}) +\`\`\` + +If not connected, inform the user they need to connect Slack from the settings/onboarding. + +## Available Tools + +### Check Connection +\`\`\` +slack-checkConnection({}) +\`\`\` +Returns whether Slack is connected and ready to use. + +### List Users +\`\`\` +slack-listUsers({ limit: 100 }) +\`\`\` +Lists users in the workspace. Use this to resolve a name to a user ID. + +### List DM Conversations +\`\`\` +slack-getDirectMessages({ limit: 50 }) +\`\`\` +Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID. + +### List Channels +\`\`\` +slack-listChannels({ types: "public_channel,private_channel", limit: 100 }) +\`\`\` +Lists channels the user has access to. + +### Get Conversation History +\`\`\` +slack-getChannelHistory({ channel: "C01234567", limit: 20 }) +\`\`\` +Fetches recent messages for a channel or DM. + +### Search Messages +\`\`\` +slack-searchMessages({ query: "in:@username", count: 20 }) +\`\`\` +Searches Slack messages using Slack search syntax. + +### Send a Message +\`\`\` +slack-sendMessage({ channel: "C01234567", text: "Hello team!" }) +\`\`\` +Sends a message to a channel or DM. Always show the draft first. + +### Execute a Slack Action +\`\`\` +slack-executeAction({ + toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY", + input: { /* tool-specific parameters */ } +}) +\`\`\` +Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`. + +### Discover Available Tools (Fallback) +\`\`\` +slack-listAvailableTools({ search: "conversation" }) +\`\`\` +Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug. + +## Composio Slack Tool Catalog (Pinned) +Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery. + +${slackToolCatalogMarkdown} + +## Workflow + +### Step 1: Check Connection +\`\`\` +slack-checkConnection({}) +\`\`\` + +### Step 2: Choose the Builtin Tool +Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing. + +## Common Tasks + +### Find the Most Recent DM with Someone +1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\` +2. If you need exact DM history: + - \`slack-listUsers({})\` to find the user ID + - \`slack-getDirectMessages({})\` to find the DM channel for that user + - \`slack-getChannelHistory({ channel: "D...", limit: 20 })\` + +### Send a Message +1. Draft the message and show it to the user +2. ONLY after user approval, send using \`slack-sendMessage\` + +### Search Messages +1. Use \`slack-searchMessages({ query: "...", count: 20 })\` + +## Best Practices + +- **Always show drafts before sending** - Never send Slack messages without user confirmation +- **Summarize, don't dump** - When showing channel history, summarize the key points +- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base + +## Error Handling + +If a Slack operation fails: +1. Try \`slack-listAvailableTools\` to verify the tool slug is correct +2. Check if Slack is still connected with \`slack-checkConnection\` +3. Inform the user of the specific error +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts new file mode 100644 index 00000000..d720c9d9 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts @@ -0,0 +1,117 @@ +export type SlackToolDefinition = { + name: string; + slug: string; + description: string; +}; + +export const slackToolCatalog: SlackToolDefinition[] = [ + { name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." }, + { name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." }, + { name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." }, + { name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." }, + { name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." }, + { name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." }, + { name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." }, + { name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." }, + { name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." }, + { name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." }, + { name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." }, + { name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." }, + { name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." }, + { name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." }, + { name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." }, + { name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." }, + { name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." }, + { name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." }, + { name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." }, + { name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." }, + { name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." }, + { name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." }, + { name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." }, + { name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." }, + { name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." }, + { name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." }, + { name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." }, + { name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." }, + { name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." }, + { name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." }, + { name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." }, + { name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." }, + { name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." }, + { name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." }, + { name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." }, + { name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." }, + { name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." }, + { name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." }, + { name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." }, + { name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." }, + { name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." }, + { name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." }, + { name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." }, + { name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." }, + { name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." }, + { name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." }, + { name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." }, + { name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." }, + { name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." }, + { name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." }, + { name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." }, + { name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." }, + { name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." }, + { name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." }, + { name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." }, + { name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." }, + { name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." }, + { name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." }, + { name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." }, + { name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." }, + { name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." }, + { name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." }, + { name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." }, + { name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." }, + { name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." }, + { name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." }, + { name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." }, + { name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." }, + { name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." }, + { name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." }, + { name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." }, + { name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." }, + { name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." }, + { name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." }, + { name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." }, + { name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." }, + { name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." }, + { name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." }, + { name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." }, + { name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." }, + { name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." }, + { name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." }, + { name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." }, + { name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." }, + { name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." }, + { name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." }, + { name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." }, + { name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." }, + { name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." }, + { name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." }, + { name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." }, + { name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." }, + { name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." }, + { name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." }, + { name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." }, + { name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." }, + { name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." }, + { name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." }, + { name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." }, + { name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." }, + { name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." }, + { name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." }, + { name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." }, + { name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." }, + { name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." }, +]; + +export const slackToolCatalogMarkdown = slackToolCatalog + .map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`) + .join("\n"); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 49cb7d46..9411eb41 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -11,6 +11,9 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js"; import * as workspace from "../../workspace/workspace.js"; import { IAgentsRepo } from "../../agents/repo.js"; import { WorkDir } from "../../config/config.js"; +import { composioAccountsRepo } from "../../composio/repo.js"; +import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; +import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -23,6 +26,232 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ }), })); +type SlackToolHint = { + search?: string; + patterns: string[]; + fallbackSlugs?: string[]; + preferSlugIncludes?: string[]; + excludePatterns?: string[]; + minScore?: number; +}; + +const slackToolHints: Record = { + sendMessage: { + search: "message", + patterns: ["send", "message", "channel"], + fallbackSlugs: [ + "SLACK_SEND_MESSAGE", + "SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL", + "SLACK_SEND_A_MESSAGE", + ], + }, + listConversations: { + search: "conversation", + patterns: ["list", "conversation", "channel"], + fallbackSlugs: [ + "SLACK_LIST_CONVERSATIONS", + "SLACK_LIST_ALL_CHANNELS", + "SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS", + "SLACK_LIST_CHANNELS", + "SLACK_LIST_CHANNEL", + ], + preferSlugIncludes: ["list", "conversation"], + minScore: 2, + }, + getConversationHistory: { + search: "history", + patterns: ["history", "conversation", "message"], + fallbackSlugs: [ + "SLACK_FETCH_CONVERSATION_HISTORY", + "SLACK_FETCHES_CONVERSATION_HISTORY", + "SLACK_GET_CONVERSATION_HISTORY", + "SLACK_GET_CHANNEL_HISTORY", + ], + preferSlugIncludes: ["history"], + minScore: 2, + }, + listUsers: { + search: "user", + patterns: ["list", "user"], + fallbackSlugs: [ + "SLACK_LIST_ALL_USERS", + "SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION", + "SLACK_LIST_USERS", + "SLACK_GET_USERS", + "SLACK_USERS_LIST", + ], + preferSlugIncludes: ["list", "user"], + excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"], + minScore: 2, + }, + getUserInfo: { + search: "user", + patterns: ["user", "info", "profile"], + fallbackSlugs: [ + "SLACK_GET_USER_INFO", + "SLACK_GET_USER", + "SLACK_USER_INFO", + ], + preferSlugIncludes: ["user", "info"], + minScore: 1, + }, + searchMessages: { + search: "search", + patterns: ["search", "message"], + fallbackSlugs: [ + "SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY", + "SLACK_SEARCH_MESSAGES", + "SLACK_SEARCH_MESSAGE", + ], + preferSlugIncludes: ["search"], + minScore: 1, + }, +}; + +const slackToolSlugCache = new Map(); + +const slackToolSlugOverrides: Partial> = { + sendMessage: "SLACK_SEND_MESSAGE", + listConversations: "SLACK_LIST_CONVERSATIONS", + getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY", + listUsers: "SLACK_LIST_ALL_USERS", + getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", + searchMessages: "SLACK_SEARCH_MESSAGES", +}; + +const compactObject = (input: Record) => + Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); + +type SlackToolResult = { success: boolean; data?: unknown; error?: string }; + +/** Helper to execute a Slack tool with consistent account validation and error handling */ +async function executeSlackTool( + hintKey: keyof typeof slackToolHints, + params: Record +): Promise { + const account = composioAccountsRepo.getAccount('slack'); + if (!account || account.status !== 'ACTIVE') { + return { success: false, error: 'Slack is not connected' }; + } + try { + const toolSlug = await resolveSlackToolSlug(hintKey); + return await executeComposioAction(toolSlug, account.id, compactObject(params)); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) => + `${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase(); + +const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => { + const slug = tool.slug.toLowerCase(); + const name = (tool.name || "").toLowerCase(); + const description = (tool.description || "").toLowerCase(); + + let score = 0; + for (const pattern of patterns) { + const needle = pattern.toLowerCase(); + if (slug.includes(needle)) score += 3; + if (name.includes(needle)) score += 2; + if (description.includes(needle)) score += 1; + } + return score; +}; + +const pickSlackTool = ( + tools: Array<{ slug: string; name?: string; description?: string }>, + hint: SlackToolHint, +) => { + let candidates = tools; + + if (hint.excludePatterns && hint.excludePatterns.length > 0) { + candidates = candidates.filter((tool) => { + const haystack = normalizeSlackTool(tool); + return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase())); + }); + } + + if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) { + const preferred = candidates.filter((tool) => + hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase())) + ); + if (preferred.length > 0) { + candidates = preferred; + } + } + + let best: { slug: string; name?: string; description?: string } | null = null; + let bestScore = 0; + + for (const tool of candidates) { + const score = scoreSlackTool(tool, hint.patterns); + if (score > bestScore) { + bestScore = score; + best = tool; + } + } + + if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) { + return null; + } + + return best; +}; + +const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => { + const cached = slackToolSlugCache.get(hintKey); + if (cached) return cached; + + const hint = slackToolHints[hintKey]; + + const override = slackToolSlugOverrides[hintKey]; + if (override && slackToolCatalog.some((tool) => tool.slug === override)) { + slackToolSlugCache.set(hintKey, override); + return override; + } + const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => { + if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) { + const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase())); + const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase())); + if (fallback) return fallback.slug; + } + + const best = pickSlackTool(tools, hint); + return best?.slug || null; + }; + + const initialTools = slackToolCatalog; + + if (!initialTools.length) { + throw new Error("No Slack tools returned from Composio"); + } + + const initialSlug = resolveFromTools(initialTools); + if (initialSlug) { + slackToolSlugCache.set(hintKey, initialSlug); + return initialSlug; + } + + const allSlug = resolveFromTools(slackToolCatalog); + + if (!allSlug) { + const fallback = await listToolkitTools("slack", hint.search || null); + const fallbackSlug = resolveFromTools(fallback.items || []); + if (!fallbackSlug) { + throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`); + } + slackToolSlugCache.set(hintKey, fallbackSlug); + return fallbackSlug; + } + + slackToolSlugCache.set(hintKey, allSlug); + return allSlug; +}; + export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -673,4 +902,162 @@ export const BuiltinTools: z.infer = { } }, }, + + // ============================================================================ + // Slack Tools (via Composio) + // ============================================================================ + + 'slack-checkConnection': { + description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.', + inputSchema: z.object({}), + execute: async () => { + if (!isComposioConfigured()) { + return { + connected: false, + error: 'Composio is not configured. Please set up your Composio API key first.', + }; + } + const account = composioAccountsRepo.getAccount('slack'); + if (!account || account.status !== 'ACTIVE') { + return { + connected: false, + error: 'Slack is not connected. Please connect Slack from the settings.', + }; + } + return { + connected: true, + accountId: account.id, + }; + }, + }, + + 'slack-listAvailableTools': { + description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.', + inputSchema: z.object({ + search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'), + }), + execute: async ({ search }: { search?: string }) => { + if (!isComposioConfigured()) { + return { success: false, error: 'Composio is not configured' }; + } + + try { + const result = await listToolkitTools('slack', search || null); + return { + success: true, + tools: result.items, + count: result.items.length, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + + 'slack-executeAction': { + description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.', + inputSchema: z.object({ + toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'), + input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'), + }), + execute: async ({ toolSlug, input }: { toolSlug: string; input: Record }) => { + const account = composioAccountsRepo.getAccount('slack'); + if (!account || account.status !== 'ACTIVE') { + return { success: false, error: 'Slack is not connected' }; + } + + try { + const result = await executeComposioAction(toolSlug, account.id, input); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + + 'slack-sendMessage': { + description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.', + inputSchema: z.object({ + channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'), + text: z.string().describe('The message text to send'), + }), + execute: async ({ channel, text }: { channel: string; text: string }) => { + return executeSlackTool("sendMessage", { channel, text }); + }, + }, + + 'slack-listChannels': { + description: 'List Slack channels the user has access to. Returns channel IDs and names.', + inputSchema: z.object({ + types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'), + limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'), + }), + execute: async ({ types, limit }: { types?: string; limit?: number }) => { + return executeSlackTool("listConversations", { + types: types || "public_channel,private_channel", + limit: limit ?? 100, + }); + }, + }, + + 'slack-getChannelHistory': { + description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.', + inputSchema: z.object({ + channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'), + limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'), + }), + execute: async ({ channel, limit }: { channel: string; limit?: number }) => { + return executeSlackTool("getConversationHistory", { + channel, + limit: limit !== undefined ? Math.min(limit, 100) : 20, + }); + }, + }, + + 'slack-listUsers': { + description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.', + inputSchema: z.object({ + limit: z.number().optional().describe('Maximum number of users to return (default: 100)'), + }), + execute: async ({ limit }: { limit?: number }) => { + return executeSlackTool("listUsers", { limit: limit ?? 100 }); + }, + }, + + 'slack-getUserInfo': { + description: 'Get detailed information about a specific Slack user by their user ID.', + inputSchema: z.object({ + user: z.string().describe('User ID to get info for (e.g., U01234567)'), + }), + execute: async ({ user }: { user: string }) => { + return executeSlackTool("getUserInfo", { user }); + }, + }, + + 'slack-searchMessages': { + description: 'Search for messages in Slack. Find messages containing specific text across channels.', + inputSchema: z.object({ + query: z.string().describe('Search query text'), + count: z.number().optional().describe('Maximum number of results (default: 20)'), + }), + execute: async ({ query, count }: { query: string; count?: number }) => { + return executeSlackTool("searchMessages", { query, count: count ?? 20 }); + }, + }, + + 'slack-getDirectMessages': { + description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.', + inputSchema: z.object({ + limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'), + }), + execute: async ({ limit }: { limit?: number }) => { + return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 }); + }, + }, }; diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts new file mode 100644 index 00000000..5a1004ce --- /dev/null +++ b/apps/x/packages/core/src/composio/client.ts @@ -0,0 +1,358 @@ +import { z } from "zod"; +import fs from "fs"; +import path from "path"; +import { Composio } from "@composio/core"; +import { WorkDir } from "../config/config.js"; +import { + ZAuthConfig, + ZConnectedAccount, + ZCreateAuthConfigRequest, + ZCreateAuthConfigResponse, + ZCreateConnectedAccountRequest, + ZCreateConnectedAccountResponse, + ZDeleteOperationResponse, + ZErrorResponse, + ZExecuteActionResponse, + ZListResponse, + ZToolkit, +} from "./types.js"; + +const BASE_URL = 'https://backend.composio.dev/api/v3'; +const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json'); + +// Composio SDK client (lazily initialized) +let composioClient: Composio | null = null; + +function getComposioClient(): Composio { + if (composioClient) { + return composioClient; + } + + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + composioClient = new Composio({ apiKey }); + return composioClient; +} + +function resetComposioClient(): void { + composioClient = null; +} + +/** + * Configuration schema for Composio + */ +const ZComposioConfig = z.object({ + apiKey: z.string().optional(), +}); + +type ComposioConfig = z.infer; + +/** + * Load Composio configuration + */ +function loadConfig(): ComposioConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); + return ZComposioConfig.parse(JSON.parse(data)); + } + } catch (error) { + console.error('[Composio] Failed to load config:', error); + } + return {}; +} + +/** + * Save Composio configuration + */ +export function saveConfig(config: ComposioConfig): void { + const dir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +/** + * Get the Composio API key + */ +export function getApiKey(): string | null { + const config = loadConfig(); + return config.apiKey || process.env.COMPOSIO_API_KEY || null; +} + +/** + * Set the Composio API key + */ +export function setApiKey(apiKey: string): void { + const config = loadConfig(); + config.apiKey = apiKey; + saveConfig(config); + resetComposioClient(); +} + +/** + * Check if Composio is configured + */ +export function isConfigured(): boolean { + return !!getApiKey(); +} + +/** + * Make an API call to Composio + */ +export async function composioApiCall( + schema: T, + url: string, + options: RequestInit = {}, +): Promise> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + console.log(`[Composio] ${options.method || 'GET'} ${url}`); + const startTime = Date.now(); + + try { + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + "x-api-key": apiKey, + ...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}), + }, + }); + + const duration = Date.now() - startTime; + console.log(`[Composio] Response in ${duration}ms`); + + const contentType = response.headers.get('content-type') || ''; + const rawText = await response.text(); + + if (!response.ok || !contentType.includes('application/json')) { + console.error(`[Composio] Error response:`, { + status: response.status, + statusText: response.statusText, + contentType, + preview: rawText.slice(0, 200), + }); + } + + if (!response.ok) { + throw new Error(`Composio API error: ${response.status} ${response.statusText}`); + } + + if (!contentType.includes('application/json')) { + throw new Error('Expected JSON response'); + } + + let data: unknown; + try { + data = JSON.parse(rawText); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + throw new Error(`Failed to parse response: ${message}`); + } + + if (typeof data === 'object' && data !== null && 'error' in data) { + const parsedError = ZErrorResponse.parse(data); + throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`); + } + + return schema.parse(data); + } catch (error) { + console.error(`[Composio] Error:`, error); + throw error; + } +} + +/** + * List available toolkits + */ +export async function listToolkits(cursor: string | null = null): Promise>>> { + const url = new URL(`${BASE_URL}/toolkits`); + url.searchParams.set("sort_by", "usage"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + return composioApiCall(ZListResponse(ZToolkit), url.toString()); +} + +/** + * Get a specific toolkit + */ +export async function getToolkit(toolkitSlug: string): Promise> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + const url = `${BASE_URL}/toolkits/${toolkitSlug}`; + console.log(`[Composio] GET ${url}`); + + const response = await fetch(url, { + headers: { "x-api-key": apiKey }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') || + data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') || + false; + + return ZToolkit.parse({ + ...data, + no_auth, + meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 }, + auth_schemes: data.auth_schemes || [], + composio_managed_auth_schemes: data.composio_managed_auth_schemes || [], + }); +} + +/** + * List auth configs for a toolkit + */ +export async function listAuthConfigs( + toolkitSlug: string, + cursor: string | null = null, + managedOnly: boolean = false +): Promise>>> { + const url = new URL(`${BASE_URL}/auth_configs`); + url.searchParams.set("toolkit_slug", toolkitSlug); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + if (managedOnly) { + url.searchParams.set("is_composio_managed", "true"); + } + return composioApiCall(ZListResponse(ZAuthConfig), url.toString()); +} + +/** + * Create an auth config + */ +export async function createAuthConfig( + request: z.infer +): Promise> { + const url = new URL(`${BASE_URL}/auth_configs`); + return composioApiCall(ZCreateAuthConfigResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Delete an auth config + */ +export async function deleteAuthConfig(authConfigId: string): Promise> { + const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`); + return composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} + +/** + * Create a connected account + */ +export async function createConnectedAccount( + request: z.infer +): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts`); + return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Get a connected account + */ +export async function getConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return composioApiCall(ZConnectedAccount, url.toString()); +} + +/** + * Delete a connected account + */ +export async function deleteConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} + +/** + * List available tools for a toolkit + */ +export async function listToolkitTools( + toolkitSlug: string, + searchQuery: string | null = null, +): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + const url = new URL(`${BASE_URL}/tools`); + url.searchParams.set('toolkit_slug', toolkitSlug); + url.searchParams.set('limit', '200'); + if (searchQuery) { + url.searchParams.set('search', searchQuery); + } + + console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`); + + const response = await fetch(url.toString(), { + headers: { "x-api-key": apiKey }, + }); + + if (!response.ok) { + throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { items?: Array> }; + + return { + items: (data.items || []).map((item) => ({ + slug: String(item.slug ?? ''), + name: String(item.name ?? ''), + description: String(item.description ?? ''), + })), + }; +} + +/** + * Execute a tool action using Composio SDK + */ +export async function executeAction( + actionSlug: string, + connectedAccountId: string, + input: Record +): Promise> { + console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`); + + try { + const client = getComposioClient(); + const result = await client.tools.execute(actionSlug, { + userId: connectedAccountId, + arguments: input, + connectedAccountId, + }); + + console.log(`[Composio] Action completed successfully`); + return { success: true, data: result.data }; + } catch (error) { + console.error(`[Composio] Action execution failed:`, error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, data: null, error: message }; + } +} diff --git a/apps/x/packages/core/src/composio/index.ts b/apps/x/packages/core/src/composio/index.ts new file mode 100644 index 00000000..a4636f99 --- /dev/null +++ b/apps/x/packages/core/src/composio/index.ts @@ -0,0 +1,5 @@ +// Composio integration for Rowboat X + +export * from './types.js'; +export * from './client.js'; +export * from './repo.js'; diff --git a/apps/x/packages/core/src/composio/repo.ts b/apps/x/packages/core/src/composio/repo.ts new file mode 100644 index 00000000..73f8f3eb --- /dev/null +++ b/apps/x/packages/core/src/composio/repo.ts @@ -0,0 +1,140 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import { WorkDir } from "../config/config.js"; +import { ZLocalConnectedAccount, LocalConnectedAccount, ConnectedAccountStatus } from "./types.js"; + +const ACCOUNTS_FILE = path.join(WorkDir, 'data', 'composio', 'connected_accounts.json'); + +/** + * Schema for the connected accounts storage file + */ +const ZConnectedAccountsStorage = z.object({ + accounts: z.record(z.string(), ZLocalConnectedAccount), // keyed by toolkit slug +}); + +type ConnectedAccountsStorage = z.infer; + +/** + * Interface for Composio accounts repository + */ +export interface IComposioAccountsRepo { + getAccount(toolkitSlug: string): LocalConnectedAccount | null; + getAllAccounts(): Record; + saveAccount(account: LocalConnectedAccount): void; + updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean; + deleteAccount(toolkitSlug: string): void; + isConnected(toolkitSlug: string): boolean; + getConnectedToolkits(): string[]; +} + +/** + * Ensure the storage directory exists + */ +function ensureStorageDir(): void { + const dir = path.dirname(ACCOUNTS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Load connected accounts from storage + */ +function loadAccounts(): ConnectedAccountsStorage { + try { + if (fs.existsSync(ACCOUNTS_FILE)) { + const data = fs.readFileSync(ACCOUNTS_FILE, 'utf-8'); + return ZConnectedAccountsStorage.parse(JSON.parse(data)); + } + } catch (error) { + console.error('[ComposioRepo] Failed to load accounts:', error); + } + return { accounts: {} }; +} + +/** + * Save connected accounts to storage + */ +function saveAccounts(storage: ConnectedAccountsStorage): void { + ensureStorageDir(); + fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(storage, null, 2)); +} + +/** + * Composio Connected Accounts Repository + * Stores connected account information locally + */ +export class ComposioAccountsRepo implements IComposioAccountsRepo { + /** + * Get a connected account by toolkit slug + */ + getAccount(toolkitSlug: string): LocalConnectedAccount | null { + const storage = loadAccounts(); + return storage.accounts[toolkitSlug] || null; + } + + /** + * Get all connected accounts + */ + getAllAccounts(): Record { + const storage = loadAccounts(); + return storage.accounts; + } + + /** + * Save a connected account + */ + saveAccount(account: LocalConnectedAccount): void { + const storage = loadAccounts(); + storage.accounts[account.toolkitSlug] = account; + saveAccounts(storage); + } + + /** + * Update account status + * @returns true if account was found and updated, false if account doesn't exist + */ + updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean { + const storage = loadAccounts(); + const account = storage.accounts[toolkitSlug]; + if (!account) { + console.warn(`[ComposioRepo] Cannot update status: account '${toolkitSlug}' not found`); + return false; + } + account.status = status; + account.lastUpdatedAt = new Date().toISOString(); + saveAccounts(storage); + return true; + } + + /** + * Delete a connected account + */ + deleteAccount(toolkitSlug: string): void { + const storage = loadAccounts(); + delete storage.accounts[toolkitSlug]; + saveAccounts(storage); + } + + /** + * Check if a toolkit is connected + */ + isConnected(toolkitSlug: string): boolean { + const account = this.getAccount(toolkitSlug); + return account?.status === 'ACTIVE'; + } + + /** + * Get list of connected toolkit slugs + */ + getConnectedToolkits(): string[] { + const storage = loadAccounts(); + return Object.entries(storage.accounts) + .filter(([, account]) => account.status === 'ACTIVE') + .map(([slug]) => slug); + } +} + +// Export singleton instance +export const composioAccountsRepo = new ComposioAccountsRepo(); diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts new file mode 100644 index 00000000..e2cbaf56 --- /dev/null +++ b/apps/x/packages/core/src/composio/types.ts @@ -0,0 +1,237 @@ +import { z } from "zod"; + +/** + * Composio authentication schemes + */ +export const ZAuthScheme = z.enum([ + 'API_KEY', + 'BASIC', + 'BASIC_WITH_JWT', + 'BEARER_TOKEN', + 'COMPOSIO_LINK', + 'SERVICE_ACCOUNT', + 'GOOGLE_SERVICE_ACCOUNT', + 'NO_AUTH', + 'OAUTH1', + 'OAUTH2', +]); + +/** + * Connected account status + */ +export const ZConnectedAccountStatus = z.enum([ + 'INITIALIZING', + 'INITIATED', + 'ACTIVE', + 'FAILED', + 'EXPIRED', + 'INACTIVE', +]); + +/** + * Toolkit metadata + */ +export const ZToolkitMeta = z.object({ + description: z.string(), + logo: z.string(), + tools_count: z.number(), + triggers_count: z.number(), +}); + +/** + * Toolkit schema + */ +export const ZToolkit = z.object({ + slug: z.string(), + name: z.string(), + meta: ZToolkitMeta, + no_auth: z.boolean(), + auth_schemes: z.array(ZAuthScheme), + composio_managed_auth_schemes: z.array(ZAuthScheme), +}); + +/** + * Tool schema + */ +export const ZTool = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkit: z.object({ + slug: z.string(), + name: z.string(), + logo: z.string(), + }), + input_parameters: z.object({ + type: z.literal('object'), + properties: z.record(z.string(), z.unknown()), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + }), + no_auth: z.boolean(), +}); + +/** + * Auth config schema + */ +export const ZAuthConfig = z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + auth_scheme: ZAuthScheme, +}); + +/** + * Credentials schema + */ +export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])); + +/** + * Create auth config request + */ +export const ZCreateAuthConfigRequest = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('use_composio_managed_auth'), + name: z.string().optional(), + credentials: ZCredentials.optional(), + }), + z.object({ + type: z.literal('use_custom_auth'), + authScheme: ZAuthScheme, + credentials: ZCredentials, + name: z.string().optional(), + }), + ]).optional(), +}); + +/** + * Create auth config response + */ +export const ZCreateAuthConfigResponse = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: ZAuthConfig, +}); + +/** + * Connection data schema + */ +export const ZConnectionData = z.object({ + authScheme: ZAuthScheme, + val: z.record(z.string(), z.unknown()) + .and(z.object({ + status: ZConnectedAccountStatus, + })), +}); + +/** + * Create connected account request + */ +export const ZCreateConnectedAccountRequest = z.object({ + auth_config: z.object({ + id: z.string(), + }), + connection: z.object({ + state: ZConnectionData.optional(), + user_id: z.string().optional(), + callback_url: z.string().optional(), + }), +}); + +/** + * Create connected account response + */ +export const ZCreateConnectedAccountResponse = z.object({ + id: z.string(), + connectionData: ZConnectionData, +}); + +/** + * Connected account schema + */ +export const ZConnectedAccount = z.object({ + id: z.string(), + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + is_disabled: z.boolean(), + }), + status: ZConnectedAccountStatus, +}); + +/** + * Error response schema + */ +export const ZErrorResponse = z.object({ + error: z.object({ + message: z.string(), + error_code: z.number(), + suggested_fix: z.string().nullable(), + errors: z.array(z.string()).nullable(), + }), +}); + +/** + * Delete operation response + */ +export const ZDeleteOperationResponse = z.object({ + success: z.boolean(), +}); + +/** + * Generic list response + */ +export const ZListResponse = (schema: T) => z.object({ + items: z.array(schema), + next_cursor: z.string().nullable(), + total_pages: z.number(), + current_page: z.number(), + total_items: z.number(), +}); + +/** + * Execute action request + */ +export const ZExecuteActionRequest = z.object({ + action: z.string(), + connected_account_id: z.string(), + input: z.record(z.string(), z.unknown()), +}); + +/** + * Execute action response + */ +export const ZExecuteActionResponse = z.object({ + success: z.boolean(), + data: z.unknown(), + error: z.string().optional(), +}); + +/** + * Local connected account storage schema + */ +export const ZLocalConnectedAccount = z.object({ + id: z.string(), + authConfigId: z.string(), + status: ZConnectedAccountStatus, + toolkitSlug: z.string(), + createdAt: z.string(), + lastUpdatedAt: z.string(), +}); + +export type AuthScheme = z.infer; +export type ConnectedAccountStatus = z.infer; +export type Toolkit = z.infer; +export type Tool = z.infer; +export type AuthConfig = z.infer; +export type ConnectedAccount = z.infer; +export type LocalConnectedAccount = z.infer; +export type ExecuteActionRequest = z.infer; +export type ExecuteActionResponse = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index e85247fe..4c83ef56 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -261,19 +261,88 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // Composio integration channels + 'composio:is-configured': { + req: z.null(), + res: z.object({ + configured: z.boolean(), + }), + }, + 'composio:set-api-key': { + req: z.object({ + apiKey: z.string(), + }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, 'auth:logout': { req: z.null(), res: z.object({ success: z.boolean(), }), }, - 'auth:didAuthenticate': { + 'composio:initiate-connection': { req: z.object({ - isAuthenticated: z.boolean(), - user: z.object({ - email: z.string(), - name: z.string().optional(), - }).nullable(), + toolkitSlug: z.string(), + }), + res: z.object({ + success: z.boolean(), + redirectUrl: z.string().optional(), + connectedAccountId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'composio:get-connection-status': { + req: z.object({ + toolkitSlug: z.string(), + }), + res: z.object({ + isConnected: z.boolean(), + status: z.string().optional(), + }), + }, + 'composio:sync-connection': { + req: z.object({ + toolkitSlug: z.string(), + connectedAccountId: z.string(), + }), + res: z.object({ + status: z.string(), + }), + }, + 'composio:disconnect': { + req: z.object({ + toolkitSlug: z.string(), + }), + res: z.object({ + success: z.boolean(), + }), + }, + 'composio:list-connected': { + req: z.null(), + res: z.object({ + toolkits: z.array(z.string()), + }), + }, + 'composio:execute-action': { + req: z.object({ + actionSlug: z.string(), + toolkitSlug: z.string(), + input: z.record(z.string(), z.unknown()), + }), + res: z.object({ + success: z.boolean(), + data: z.unknown(), + error: z.string().optional(), + }), + }, + 'composio:didConnect': { + req: z.object({ + toolkitSlug: z.string(), + success: z.boolean(), + error: z.string().optional(), }), res: z.null(), }, diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5ea5f4d3..31db92c0 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 + '@composio/core': + specifier: ^0.1.48 + version: 0.1.55(zod@4.2.1) '@google-cloud/local-auth': specifier: ^3.0.1 version: 3.0.1(encoding@0.1.13) @@ -674,6 +677,19 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@composio/client@0.1.0-alpha.37': + resolution: {integrity: sha512-2YzXiRXlxqOgEz7nEh1aKdJ9vwQRb69+RX6UH+LTDmuTwW+pOjI2qbhIBxTbP2z9e2SyHVG0/AtMxc9tR5jU0w==} + + '@composio/core@0.1.55': + resolution: {integrity: sha512-GLSWTS/gZeycQ7W2wSZQ21DKV+LC6WTAilhSj+JG9Apslx3re9luF1Lyblm4UMSf4DzYWKOrtipwQOaeg7bmTg==} + peerDependencies: + zod: '>=3.25.76 <5' + + '@composio/json-schema-to-zod@0.1.16': + resolution: {integrity: sha512-vu6RUQTWDW/0wLDWsQHtWJ97JsnMjA1olTQwVD88BAtKZTdDLuzjnGRxSllxp5JG/GntnovUphraZWcWfe80eQ==} + peerDependencies: + zod: '>=3.25.76 <5' + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -5375,6 +5391,18 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openai@5.23.2: + resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} @@ -5692,6 +5720,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pusher-js@8.4.0: + resolution: {integrity: sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -6310,6 +6341,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6469,6 +6503,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -7374,6 +7412,27 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@composio/client@0.1.0-alpha.37': {} + + '@composio/core@0.1.55(zod@4.2.1)': + dependencies: + '@composio/client': 0.1.0-alpha.37 + '@composio/json-schema-to-zod': 0.1.16(zod@4.2.1) + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + openai: 5.23.2(zod@4.2.1) + pusher-js: 8.4.0 + semver: 7.7.3 + uuid: 13.0.0 + zod: 4.2.1 + zod-to-json-schema: 3.25.1(zod@4.2.1) + transitivePeerDependencies: + - ws + + '@composio/json-schema-to-zod@0.1.16(zod@4.2.1)': + dependencies: + zod: 4.2.1 + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)': dependencies: '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2) @@ -13026,6 +13085,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@5.23.2(zod@4.2.1): + optionalDependencies: + zod: 4.2.1 + openid-client@6.8.1: dependencies: jose: 6.1.3 @@ -13386,6 +13449,10 @@ snapshots: punycode@2.3.1: {} + pusher-js@8.4.0: + dependencies: + tweetnacl: 1.0.3 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -14110,6 +14177,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -14269,6 +14338,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@9.0.1: {} validate-npm-package-license@3.0.4: