diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index cd21f0c..3a45e10 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.61", + "version": "0.1.62", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index f4cc9e7..0add274 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -413,6 +413,15 @@ async function streamAIResponse( delete (toolsToUse as Record).firecrawl_scrape delete (toolsToUse as Record).firecrawl_map } + // If composio MCP tools are available, instruct the AI to prefer them + // over built-in tools for the corresponding services. + const hasComposioTools = Object.keys(toolsToUse).some((k) => + k.startsWith("mcp_composio_") + ) + if (hasComposioTools && aiMessages[0]) { + aiMessages[0].content += `\n\n## MCP Tool Preference\n\nYou have composio-connected MCP tools available (prefixed with mcp_composio_). These provide direct access to services like GitHub, Linear, Slack, etc. When a user's request can be satisfied using these MCP tools, prefer them over running commands via run_command or other built-in tools. For example, use mcp_composio_github_* tools for GitHub operations instead of running gh CLI commands.` + } + // Wire the subagent runtime so the `delegate` tool can spawn focused subtasks. setDelegateRuntime({ model: (provider as any).model ?? null, diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/mcp.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/mcp.ts index edc68c8..d68cead 100644 --- a/apps/supercode-cli/server/src/cli/commands/slashCommands/mcp.ts +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/mcp.ts @@ -1,5 +1,5 @@ import * as readline from "node:readline" -import { select, text, password, isCancel } from "@clack/prompts" +import { select, text, isCancel } from "@clack/prompts" import chalk from "chalk" let _mgr: any = null async function mcpManager(): Promise { @@ -70,6 +70,23 @@ class McpPicker { detail: "API key set — connect to start", }) } + } else { + // No local API key — try server-side proxy + try { + const apps = await composioSessionManager.listAppsFromServer() + for (const app of apps) { + list.push({ + id: `app:${app.slug}`, + name: app.name, + type: "composio-app", + status: app.connected ? "connected" : "disconnected", + detail: app.connected ? "OAuth connected" : "click to connect with OAuth", + appSlug: app.slug, + }) + } + } catch { + // server-side also unavailable — no composio apps shown + } } for (const [name, srv] of Object.entries(servers)) { @@ -320,7 +337,6 @@ async function connectFlow(): Promise { const mgr = await mcpManager() // Try server-side session first (no local API key needed) - let sessionErr: Error | null = null try { const info = await composioSessionManager.createSessionFromServer() const config = await getCliConfig() @@ -334,46 +350,11 @@ async function connectFlow(): Promise { console.log(`\n ${chalk.hex(theme.green)("◆")} composio connected — ${Object.keys(tools).length} tools`) return } catch (err: any) { - sessionErr = err - } - - // Fall back to local API key - if (!composioSessionManager.isConfigured) { - const apiKey = (await password({ - message: "Composio API key (set COMPOSIO_API_KEY in .env)", - })) as string - if (isCancel(apiKey)) return - - if (apiKey.trim()) { - process.env.COMPOSIO_API_KEY = apiKey.trim() - const { ComposioSessionManager } = await import("src/mcp/composio") - const config = await getCliConfig() - const existing = (config as Record) ?? {} - await saveCliConfig({ - ...existing as any, - composioApiKey: apiKey.trim(), - } as any) - Object.assign(composioSessionManager, new ComposioSessionManager()) + console.log(`\n ${chalk.hex(theme.red)("◆")} composio connection failed: ${err.message}`) + if (!composioSessionManager.isConfigured) { + console.log(` ${chalk.hex(theme.muted)(" ")}Set COMPOSIO_API_KEY in .env for local development, or run ${chalk.hex(theme.green)("supercode login")}`) } } - - try { - const info = await composioSessionManager.createSession("supercode-cli") - const config = await getCliConfig() - const existing = (config as Record) ?? {} - await saveCliConfig({ - ...existing as any, - composioSessionId: info.sessionId, - } as any) - await mgr.reconnectServer("composio", { url: info.url, headers: info.headers }) - const tools = await mgr.getTools("composio") - console.log(`\n ${chalk.hex(theme.green)("◆")} composio connected — ${Object.keys(tools).length} tools`) - } catch (err: any) { - const msg = !composioSessionManager.isConfigured - ? "Server unreachable and no local API key set" - : err.message - console.log(`\n ${chalk.hex(theme.red)("◆")} composio connection failed: ${msg}`) - } return } @@ -589,79 +570,86 @@ async function showInteractiveList(): Promise { const wasRaw = process.stdin.isRaw if (process.stdin.isTTY) process.stdin.setRawMode(true) - while (true) { - const key = await readRawKey() - - if (key === "escape") { - clearLines(picker.overlayLines + 1) - break - } - - if (key === "up") { - picker.selectPrev() - clearLines(picker.overlayLines) - draw() - continue - } - - if (key === "down") { - picker.selectNext() - clearLines(picker.overlayLines) - draw() - continue - } - - if (key === "enter") { - clearLines(picker.overlayLines + 1) + try { + while (true) { + const key = await readRawKey() - if (picker.selected === -1) { - await connectFlow() - } else { - const entry = picker.getSelectedEntry() - if (entry && entry.type === "composio-app" && entry.status === "disconnected" && entry.appSlug) { - await oauthConnectAppFlow(entry.appSlug) - } else if (entry) { - await showDetail(entry) - } + if (key === "escape") { + break } - await picker.refresh() + if (key === "up") { + picker.selectPrev() + clearLines(picker.overlayLines) + draw() + continue + } - if (process.stdin.isTTY) process.stdin.setRawMode(true) - process.stdout.write("\n") - draw() - continue - } + if (key === "down") { + picker.selectNext() + clearLines(picker.overlayLines) + draw() + continue + } - if (key === "space") { - const entry = picker.getSelectedEntry() - if (entry) { - const mgr = await mcpManager() - const connected = mgr.connectedServers + if (key === "enter") { + clearLines(picker.overlayLines + 1) - if (connected.includes(entry.name)) { - await mgr.stopServer(entry.name) - if (entry.name === "composio") { - composioSessionManager.resetSession() - } - } else { - const cfg = await buildServerConfig(entry) - if (cfg) { - try { - await mgr.reconnectServer(entry.name, cfg) - } catch {} + try { + if (picker.selected === -1) { + await connectFlow() + } else { + const entry = picker.getSelectedEntry() + if (entry && entry.type === "composio-app" && entry.status === "disconnected" && entry.appSlug) { + await oauthConnectAppFlow(entry.appSlug) + } else if (entry) { + await showDetail(entry) + } } + } catch { + // @clack/prompts may leave stdin paused; ensure it's flowing } await picker.refresh() - clearLines(picker.overlayLines) + + if (process.stdin.isTTY) process.stdin.setRawMode(true) + process.stdout.write("\n") draw() + continue + } + + if (key === "space") { + const entry = picker.getSelectedEntry() + if (entry) { + const mgr = await mcpManager() + const connected = mgr.connectedServers + + if (connected.includes(entry.name)) { + await mgr.stopServer(entry.name) + if (entry.name === "composio") { + composioSessionManager.resetSession() + } + } else { + const cfg = await buildServerConfig(entry) + if (cfg) { + try { + await mgr.reconnectServer(entry.name, cfg) + } catch {} + } + } + + await picker.refresh() + clearLines(picker.overlayLines) + draw() + } + continue } - continue } + } finally { + if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw ?? false) + process.stdin.resume() + clearLines(picker.overlayLines + 1) } - - if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw ?? false) } async function oauthConnectAppFlow(slug: string): Promise { diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index d4c5ab0..be0366b 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -1103,12 +1103,73 @@ app.post("/api/composio/session", async (req, res) => { url: (s as any).mcp.url as string, headers: (s as any).mcp.headers as Record, sessionId: (s as any).session_id as string, + apiKey, }) } catch (error: any) { res.status(500).json({ error: error.message || "Composio session creation failed" }) } }) +app.post("/api/composio/apps", async (req, res) => { + try { + const user = await getUserFromBearer(req) + if (!user) { res.status(401).json({ error: "Unauthorized" }); return } + + const apiKey = process.env.COMPOSIO_API_KEY + if (!apiKey) { res.status(500).json({ error: "Composio not configured on server" }); return } + + const { Composio } = await import("@composio/core") + const composio = new Composio({ apiKey }) + + const [authConfigs, toolkits, connectedRes] = await Promise.all([ + (composio as any).authConfigs.list({}), + (composio.toolkits as any).get(), + (composio.connectedAccounts as any).list({}), + ]) + + const configuredSlugs = new Set( + (authConfigs.items ?? []).map((ac: any) => ac.toolkit?.slug).filter(Boolean), + ) + + const connectedMap = new Map() + for (const acct of connectedRes.items ?? []) { + const slug: string = acct.toolkit?.slug + if (slug && acct.status === "ACTIVE") { + connectedMap.set(slug, acct.id) + } + } + + const toolkitMap = new Map() + for (const tk of toolkits) { + toolkitMap.set(tk.slug, tk) + } + + const apps: any[] = [] + for (const slug of configuredSlugs) { + const tk = toolkitMap.get(slug) + if (!tk) continue + const conn = connectedMap.get(slug) + apps.push({ + slug: tk.slug, + name: tk.name, + description: tk.meta?.description ?? "", + logo: tk.meta?.logo, + connected: !!conn, + connectedAccountId: conn ?? null, + }) + } + + apps.sort((a, b) => { + if (a.connected !== b.connected) return a.connected ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + res.json({ apps }) + } catch (error: any) { + res.status(500).json({ error: error.message || "Composio list apps failed" }) + } +}) + app.post("/api/tools/web-search", async (req, res) => { try { const user = await getUserFromBearer(req) diff --git a/apps/supercode-cli/server/src/mcp/composio.ts b/apps/supercode-cli/server/src/mcp/composio.ts index d0f7d02..0b91d1d 100644 --- a/apps/supercode-cli/server/src/mcp/composio.ts +++ b/apps/supercode-cli/server/src/mcp/composio.ts @@ -7,6 +7,7 @@ export interface ComposioSessionInfo { url: string headers: Record sessionId: string + apiKey?: string } export interface AppEntry { @@ -78,6 +79,15 @@ export class ComposioSessionManager { const info = (await res.json()) as ComposioSessionInfo this.session = info + + if (info.apiKey && !this.composio) { + try { + this.composio = new Composio({ apiKey: info.apiKey }) + } catch { + // SDK init from server key failed — listApps won't work locally + } + } + return info } @@ -115,6 +125,41 @@ export class ComposioSessionManager { return result } + async listAppsFromServer( + serverUrl = BASE_URL, + accessToken?: string, + ): Promise { + if (!accessToken) { + const stored = await getStoredToken() + accessToken = stored?.access_token as string + } + if (!accessToken) { + throw new Error("Not authenticated — run supercode login first") + } + + const res = await fetch(`${serverUrl}/api/composio/apps`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as any).error || `Server returned ${res.status}`) + } + + const data = (await res.json()) as { apps: any[] } + return data.apps.map((a) => ({ + slug: a.slug, + name: a.name, + description: a.description, + logo: a.logo, + connected: a.connected, + connectedAccountId: a.connectedAccountId, + })) + } + resetSession(): void { this.session = null }