clementine-agent 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1639,7 +1639,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1639
1639
  };
1640
1640
  }
1641
1641
  // ── Build SDK Options ─────────────────────────────────────────────
1642
- buildOptions(opts = {}) {
1642
+ async buildOptions(opts = {}) {
1643
1643
  const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, thinking, outputFormat, stallGuard, intentClassification, } = opts;
1644
1644
  let allowedTools = [
1645
1645
  'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
@@ -1928,6 +1928,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1928
1928
  }
1929
1929
  }
1930
1930
  catch { /* non-fatal — run with just Clementine's own server */ }
1931
+ // Composio toolkits — each connected toolkit becomes an in-process MCP
1932
+ // server (mcp__gmail__*, mcp__slack__*, …). Profile-level allowlist
1933
+ // (profile.allowedComposioToolkits) constrains which toolkits this agent
1934
+ // sees; omit it to surface every active connection.
1935
+ let composioMcpServers = {};
1936
+ if (!disableAllTools && !isPlanStep) {
1937
+ try {
1938
+ const { buildComposioMcpServers } = await import('../integrations/composio/mcp-bridge.js');
1939
+ const allowList = profile?.allowedComposioToolkits;
1940
+ composioMcpServers = await buildComposioMcpServers(allowList);
1941
+ }
1942
+ catch (err) {
1943
+ // Composio is purely additive — never block the agent if it fails.
1944
+ logger.debug({ err }, 'Composio MCP servers unavailable');
1945
+ }
1946
+ }
1931
1947
  // Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
1932
1948
  // terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
1933
1949
  const effectivePermissionMode = 'bypassPermissions';
@@ -1973,6 +1989,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1973
1989
  },
1974
1990
  },
1975
1991
  ...externalMcpServers,
1992
+ ...composioMcpServers,
1976
1993
  },
1977
1994
  ...(abortController ? { abortController } : {}),
1978
1995
  maxTurns: effectiveMaxTurns,
@@ -2602,7 +2619,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2602
2619
  let contradictionRetried = false;
2603
2620
  try {
2604
2621
  for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
2605
- const sdkOptions = this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
2622
+ const sdkOptions = await this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
2606
2623
  // If a project matched, switch cwd so the agent gets its tools/CLAUDE.md
2607
2624
  if (matchedProject) {
2608
2625
  sdkOptions.cwd = matchedProject.path;
@@ -3851,7 +3868,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3851
3868
  // ── Heartbeat / Cron ──────────────────────────────────────────────
3852
3869
  async heartbeat(standingInstructions, changesSummary = '', timeContext = '', dedupContext = '', profile) {
3853
3870
  setInteractionSource('autonomous');
3854
- const sdkOptions = this.buildOptions({
3871
+ const sdkOptions = await this.buildOptions({
3855
3872
  isHeartbeat: true,
3856
3873
  enableTeams: false,
3857
3874
  model: MODELS.haiku,
@@ -3921,7 +3938,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3921
3938
  // Don't mutate the global — pass source through the closure instead
3922
3939
  // Per-step stall guard so concurrent steps don't cross-contaminate
3923
3940
  const stepGuard = new StallGuard();
3924
- const sdkOptions = this.buildOptions({
3941
+ const sdkOptions = await this.buildOptions({
3925
3942
  isHeartbeat: false,
3926
3943
  cronTier: tier,
3927
3944
  maxTurns,
@@ -3986,7 +4003,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3986
4003
  // not chat text — pass mode='cron' so high_effort_low_output guard is
3987
4004
  // disabled. Loop detection and circular-reasoning checks stay active.
3988
4005
  const cronGuard = new StallGuard('cron');
3989
- const sdkOptions = this.buildOptions({
4006
+ const sdkOptions = await this.buildOptions({
3990
4007
  isHeartbeat: true,
3991
4008
  cronTier: tier,
3992
4009
  maxTurns: maxTurns ?? (tier >= 2 ? 30 : 15),
@@ -4473,7 +4490,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4473
4490
  _setActiveQueryContext({ job: jobName, source: 'unleashed', agentSlug });
4474
4491
  // Unleashed phases run side-effect-heavy work; same logic as cron mode.
4475
4492
  const phaseGuard = new StallGuard('unleashed');
4476
- const sdkOptions = this.buildOptions({
4493
+ const sdkOptions = await this.buildOptions({
4477
4494
  isHeartbeat: true,
4478
4495
  cronTier: tier,
4479
4496
  maxTurns: turnsPerPhase,
@@ -4830,7 +4847,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4830
4847
  logger.info({ taskName, phase }, `Team task: starting phase ${phase}`);
4831
4848
  setInteractionSource('autonomous');
4832
4849
  const teamGuard = new StallGuard();
4833
- const sdkOptions = this.buildOptions({
4850
+ const sdkOptions = await this.buildOptions({
4834
4851
  isHeartbeat: true,
4835
4852
  cronTier: 2, // Give full tool access (Bash, Write, Edit)
4836
4853
  maxTurns: turnsPerPhase,
@@ -4386,6 +4386,132 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4386
4386
  res.status(500).json({ error: String(err) });
4387
4387
  }
4388
4388
  });
4389
+ // ── Composio (1000+ third-party services via OAuth broker) ────
4390
+ app.get('/api/composio/status', (_req, res) => {
4391
+ res.json({ enabled: Boolean(process.env.COMPOSIO_API_KEY) });
4392
+ });
4393
+ app.get('/api/composio/toolkits', async (_req, res) => {
4394
+ try {
4395
+ if (!process.env.COMPOSIO_API_KEY) {
4396
+ res.json({ enabled: false, toolkits: [] });
4397
+ return;
4398
+ }
4399
+ const c = await import('../integrations/composio/client.js');
4400
+ const [connected, configured, meta] = await Promise.all([
4401
+ c.listConnectedToolkits(),
4402
+ c.listToolkitSlugsWithAuthConfig(),
4403
+ c.listToolkitMeta(),
4404
+ ]);
4405
+ const connectionsBySlug = new Map();
4406
+ for (const conn of connected) {
4407
+ const arr = connectionsBySlug.get(conn.slug) ?? [];
4408
+ arr.push(conn);
4409
+ connectionsBySlug.set(conn.slug, arr);
4410
+ }
4411
+ for (const arr of connectionsBySlug.values()) {
4412
+ arr.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
4413
+ }
4414
+ const toView = (conn) => ({
4415
+ id: conn.connectionId,
4416
+ status: conn.status,
4417
+ alias: conn.alias ?? null,
4418
+ accountLabel: conn.accountLabel ?? null,
4419
+ accountEmail: conn.accountEmail ?? null,
4420
+ accountName: conn.accountName ?? null,
4421
+ accountAvatarUrl: conn.accountAvatarUrl ?? null,
4422
+ createdAt: conn.createdAt ?? null,
4423
+ });
4424
+ const curated = c.CURATED_TOOLKITS.map(t => {
4425
+ const m = meta.get(t.slug);
4426
+ const conns = connectionsBySlug.get(t.slug) ?? [];
4427
+ return {
4428
+ slug: t.slug,
4429
+ displayName: t.displayName,
4430
+ authMode: t.authMode,
4431
+ hasAuthConfig: configured.has(t.slug),
4432
+ logoUrl: m?.logo ?? null,
4433
+ description: m?.description ?? null,
4434
+ toolCount: m?.toolsCount ?? null,
4435
+ connections: conns.map(toView),
4436
+ };
4437
+ });
4438
+ const extras = [...connectionsBySlug.entries()]
4439
+ .filter(([slug]) => !c.CURATED_TOOLKITS.some(t => t.slug === slug))
4440
+ .map(([slug, conns]) => {
4441
+ const m = meta.get(slug);
4442
+ // Non-curated: infer auth mode. If a custom auth config exists,
4443
+ // user set it up themselves (BYO). Otherwise it must be managed.
4444
+ const authMode = configured.has(slug) ? 'byo' : 'managed';
4445
+ return {
4446
+ slug,
4447
+ displayName: m?.name ?? c.displayNameFor(slug),
4448
+ authMode,
4449
+ hasAuthConfig: configured.has(slug),
4450
+ logoUrl: m?.logo ?? null,
4451
+ description: m?.description ?? null,
4452
+ toolCount: m?.toolsCount ?? null,
4453
+ connections: conns.map(toView),
4454
+ };
4455
+ });
4456
+ res.json({ enabled: true, toolkits: [...curated, ...extras] });
4457
+ }
4458
+ catch (err) {
4459
+ res.status(500).json({ error: String(err) });
4460
+ }
4461
+ });
4462
+ app.post('/api/composio/toolkits/:slug/authorize', async (req, res) => {
4463
+ const slug = req.params.slug;
4464
+ const alias = typeof req.body?.alias === 'string' ? req.body.alias : undefined;
4465
+ try {
4466
+ const c = await import('../integrations/composio/client.js');
4467
+ const result = await c.authorizeToolkit(slug, alias ? { alias } : undefined);
4468
+ res.json(result);
4469
+ }
4470
+ catch (err) {
4471
+ const c = await import('../integrations/composio/client.js');
4472
+ if (err instanceof c.ComposioNeedsAuthConfigError) {
4473
+ res.status(409).json({
4474
+ error: err.message,
4475
+ needsAuthConfig: true,
4476
+ toolkit: slug,
4477
+ setupUrl: 'https://platform.composio.dev/auth-configs',
4478
+ });
4479
+ return;
4480
+ }
4481
+ res.status(500).json({ error: String(err) });
4482
+ }
4483
+ });
4484
+ app.post('/api/composio/toolkits/:slug/disconnect', async (req, res) => {
4485
+ const connectionId = typeof req.body?.connectionId === 'string' ? req.body.connectionId : '';
4486
+ if (!connectionId) {
4487
+ res.status(400).json({ error: 'connectionId required in body' });
4488
+ return;
4489
+ }
4490
+ try {
4491
+ const c = await import('../integrations/composio/client.js');
4492
+ await c.disconnectToolkit(connectionId);
4493
+ res.json({ ok: true });
4494
+ }
4495
+ catch (err) {
4496
+ res.status(500).json({ error: String(err) });
4497
+ }
4498
+ });
4499
+ app.post('/api/composio/connections/:id/rename', async (req, res) => {
4500
+ const id = req.params.id;
4501
+ const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim() : '';
4502
+ if (!alias) {
4503
+ res.status(400).json({ error: 'alias required in body' });
4504
+ return;
4505
+ }
4506
+ try {
4507
+ const c = await import('../integrations/composio/client.js');
4508
+ await c.renameConnection(id, alias);
4509
+ res.json({ ok: true });
4510
+ }
4511
+ catch (err) {
4512
+ res.status(500).json({ error: String(err) });
4513
+ }
4514
+ });
4389
4515
  // ── CRON CRUD routes ──────────────────────────────────────────
4390
4516
  app.get('/api/projects', (_req, res) => {
4391
4517
  try {
@@ -4968,6 +5094,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4968
5094
  { key: 'MS_USER_EMAIL', label: 'User Email', hint: 'Email address for mail/calendar access' },
4969
5095
  ],
4970
5096
  },
5097
+ {
5098
+ label: 'Composio',
5099
+ keys: [
5100
+ { key: 'COMPOSIO_API_KEY', label: 'API Key', hint: 'Get one at https://app.composio.dev → Developers. Unlocks 1000+ services (Gmail, Slack, Notion, …) for the agent.', type: 'password' },
5101
+ { key: 'COMPOSIO_USER_ID', label: 'User ID', hint: 'Optional — keys all connections under this Composio user. Defaults to "clementine-default".' },
5102
+ ],
5103
+ },
4971
5104
  {
4972
5105
  label: 'Salesforce',
4973
5106
  keys: [
@@ -5088,13 +5221,22 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5088
5221
  const { setEnable1MContext } = await import('../config.js');
5089
5222
  setEnable1MContext(value.toLowerCase() === 'true');
5090
5223
  }
5224
+ // Composio: mutate process.env in-place + drop singleton so the very
5225
+ // next /api/composio/* call picks up the new key without a daemon
5226
+ // restart. Without this, "Save key → Connect Gmail" would 503 until
5227
+ // the user restarted, which defeats the dashboard-config UX.
5228
+ if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
5229
+ process.env[key] = value;
5230
+ const { resetComposioClient } = await import('../integrations/composio/client.js');
5231
+ resetComposioClient();
5232
+ }
5091
5233
  res.json({ ok: true, message: `Updated ${key}` });
5092
5234
  }
5093
5235
  catch (err) {
5094
5236
  res.status(500).json({ error: String(err) });
5095
5237
  }
5096
5238
  });
5097
- app.delete('/api/settings/:key', (req, res) => {
5239
+ app.delete('/api/settings/:key', async (req, res) => {
5098
5240
  try {
5099
5241
  const { key } = req.params;
5100
5242
  if (!existsSync(ENV_PATH)) {
@@ -5105,6 +5247,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5105
5247
  const re = new RegExp(`^${key}=.*\n?`, 'm');
5106
5248
  content = content.replace(re, '');
5107
5249
  writeFileSync(ENV_PATH, content);
5250
+ // Hot-reload mirror of the PUT handler — drop process.env entry +
5251
+ // reset Composio client so removal takes effect without a restart.
5252
+ if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
5253
+ delete process.env[key];
5254
+ const { resetComposioClient } = await import('../integrations/composio/client.js');
5255
+ resetComposioClient();
5256
+ }
5108
5257
  res.json({ ok: true, message: `Removed ${key}` });
5109
5258
  }
5110
5259
  catch (err) {
@@ -14325,6 +14474,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14325
14474
  </p>
14326
14475
  </div>
14327
14476
  <div class="tab-pane" id="tab-settings-integrations">
14477
+ <div class="card" style="margin-bottom:20px">
14478
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14479
+ <span>Connections (via Composio)</span>
14480
+ <span style="font-size:11px;color:var(--text-muted)">1000+ services — OAuth handled by Composio</span>
14481
+ </div>
14482
+ <div class="card-body" style="padding:16px" id="composio-connections">
14483
+ <div class="empty-state">Loading...</div>
14484
+ </div>
14485
+ </div>
14328
14486
  <div class="card" style="margin-bottom:20px">
14329
14487
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14330
14488
  <span>Claude Desktop Integrations</span>
@@ -15640,7 +15798,7 @@ function switchTab(group, tab) {
15640
15798
  if (tab === 'learning' && typeof refreshSelfImprove === 'function') refreshSelfImprove();
15641
15799
  }
15642
15800
  if (group === 'settings') {
15643
- if (tab === 'integrations') refreshSalesforce();
15801
+ if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); }
15644
15802
  if (tab === 'remote') refreshRemoteAccess();
15645
15803
  if (tab === 'security') refreshAuthSessions();
15646
15804
  if (tab === 'projects' && typeof refreshProjects === 'function') refreshProjects();
@@ -25574,6 +25732,147 @@ async function refreshClaudeIntegrations(preloaded) {
25574
25732
  }
25575
25733
  }
25576
25734
 
25735
+ async function refreshComposioConnections() {
25736
+ var container = document.getElementById('composio-connections');
25737
+ if (!container) return;
25738
+ try {
25739
+ var r = await apiFetch('/api/composio/toolkits');
25740
+ var d = await r.json();
25741
+ if (!d.enabled) {
25742
+ container.innerHTML =
25743
+ '<div style="padding:8px 4px">' +
25744
+ '<div style="font-size:13px;margin-bottom:12px">Composio gives Clementine OAuth access to 1000+ services (Gmail, Slack, Notion, GitHub, …) from any channel — Telegram, Discord, dashboard chat. The agent never sees the credentials.</div>' +
25745
+ '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">Get your free API key at <a href="https://app.composio.dev/developers" target="_blank" style="color:var(--accent)">app.composio.dev/developers</a>, then paste it here:</div>' +
25746
+ '<div style="display:flex;gap:8px;align-items:center">' +
25747
+ '<input type="password" id="composio-key-input" placeholder="cak_..." style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:monospace">' +
25748
+ '<button class="btn btn-sm btn-primary" onclick="saveComposioApiKey()" style="font-size:12px">Save & Activate</button>' +
25749
+ '</div>' +
25750
+ '<div id="composio-key-status" style="font-size:11px;color:var(--text-muted);margin-top:8px"></div>' +
25751
+ '</div>';
25752
+ var input = document.getElementById('composio-key-input');
25753
+ if (input) input.addEventListener('keydown', function(e) { if (e.key === 'Enter') saveComposioApiKey(); });
25754
+ return;
25755
+ }
25756
+ var toolkits = d.toolkits || [];
25757
+ if (toolkits.length === 0) {
25758
+ container.innerHTML = '<div class="empty-state" style="padding:16px">No toolkits available. Check that your Composio API key is valid.</div>';
25759
+ return;
25760
+ }
25761
+
25762
+ var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';
25763
+ toolkits.forEach(function(t) {
25764
+ var connected = (t.connections || []).filter(function(c) { return c.status === 'ACTIVE'; });
25765
+ var pending = (t.connections || []).filter(function(c) { return c.status !== 'ACTIVE'; });
25766
+ var isConnected = connected.length > 0;
25767
+ var statusColor = isConnected ? 'var(--green)' : (pending.length > 0 ? 'var(--yellow,#f59e0b)' : 'var(--text-muted)');
25768
+ var byo = t.authMode === 'byo' && !t.hasAuthConfig;
25769
+
25770
+ html += '<div style="border:1px solid var(--border);border-radius:8px;padding:12px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:8px">';
25771
+ html += '<div style="display:flex;align-items:center;gap:10px">';
25772
+ if (t.logoUrl) {
25773
+ html += '<img src="' + esc(t.logoUrl) + '" alt="" style="width:24px;height:24px;border-radius:4px;background:#fff;padding:2px;flex-shrink:0;object-fit:contain">';
25774
+ } else {
25775
+ html += '<div style="width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);flex-shrink:0"></div>';
25776
+ }
25777
+ html += '<div style="flex:1;min-width:0">';
25778
+ html += '<div style="font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(t.displayName) + '</div>';
25779
+ html += '<div style="font-size:10px;color:var(--text-muted)">' + (isConnected ? connected.length + ' account' + (connected.length !== 1 ? 's' : '') : 'Not connected') + '</div>';
25780
+ html += '</div>';
25781
+ html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>';
25782
+ html += '</div>';
25783
+
25784
+ if (connected.length > 0) {
25785
+ html += '<div style="display:flex;flex-direction:column;gap:4px">';
25786
+ connected.forEach(function(c) {
25787
+ var label = c.alias || c.accountLabel || c.accountEmail || c.accountName || 'connected';
25788
+ html += '<div style="display:flex;align-items:center;gap:6px;font-size:11px">';
25789
+ html += '<span style="flex:1;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(label) + '</span>';
25790
+ html += '<button class="btn-sm" onclick="disconnectComposio(\\'' + esc(t.slug) + '\\',\\'' + esc(c.id) + '\\')" style="font-size:10px;padding:2px 6px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:4px;cursor:pointer">Disconnect</button>';
25791
+ html += '</div>';
25792
+ });
25793
+ html += '</div>';
25794
+ }
25795
+
25796
+ if (byo) {
25797
+ html += '<div style="font-size:10px;color:var(--yellow,#f59e0b);background:rgba(245,158,11,0.08);padding:6px 8px;border-radius:4px;line-height:1.4">Needs a BYO OAuth app — <a href="https://platform.composio.dev/auth-configs" target="_blank" style="color:inherit;text-decoration:underline">set up in Composio</a> first.</div>';
25798
+ }
25799
+
25800
+ html += '<button class="btn btn-sm btn-primary" onclick="connectComposio(\\'' + esc(t.slug) + '\\')" style="font-size:11px">' + (isConnected ? '+ Add another account' : 'Connect') + '</button>';
25801
+ html += '</div>';
25802
+ });
25803
+ html += '</div>';
25804
+ container.innerHTML = html;
25805
+ } catch (e) {
25806
+ container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:16px">Failed to load Composio toolkits: ' + esc(String(e)) + '</div>';
25807
+ }
25808
+ }
25809
+
25810
+ async function saveComposioApiKey() {
25811
+ var input = document.getElementById('composio-key-input');
25812
+ var status = document.getElementById('composio-key-status');
25813
+ if (!input) return;
25814
+ var key = (input.value || '').trim();
25815
+ if (!key) { if (status) { status.textContent = 'Enter a key first.'; status.style.color = 'var(--red)'; } return; }
25816
+ if (status) { status.textContent = 'Saving…'; status.style.color = 'var(--text-muted)'; }
25817
+ try {
25818
+ await apiJson('PUT', '/api/settings/COMPOSIO_API_KEY', { value: key });
25819
+ if (status) { status.textContent = 'Saved. Loading toolkits…'; status.style.color = 'var(--green)'; }
25820
+ toast('Composio API key saved', 'success');
25821
+ setTimeout(refreshComposioConnections, 400);
25822
+ } catch (e) {
25823
+ if (status) { status.textContent = 'Failed to save: ' + e; status.style.color = 'var(--red)'; }
25824
+ toast('Failed to save key: ' + e, 'error');
25825
+ }
25826
+ }
25827
+
25828
+ async function connectComposio(slug) {
25829
+ try {
25830
+ var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/authorize', {
25831
+ method: 'POST',
25832
+ credentials: 'same-origin',
25833
+ headers: { 'content-type': 'application/json' },
25834
+ body: JSON.stringify({}),
25835
+ });
25836
+ var d = await res.json();
25837
+ if (res.status === 409 && d.needsAuthConfig) {
25838
+ toast('This toolkit needs a BYO OAuth app — opening Composio dashboard.', 'warn');
25839
+ window.open(d.setupUrl, '_blank');
25840
+ return;
25841
+ }
25842
+ if (!res.ok) { toast('Connect failed: ' + (d.error || res.status), 'error'); return; }
25843
+ if (d.redirectUrl) {
25844
+ window.open(d.redirectUrl, '_blank');
25845
+ toast('Opened ' + slug + ' authorization in a new tab. Refresh after approving.', 'info');
25846
+ // Soft poll to catch the new connection without forcing a manual refresh.
25847
+ setTimeout(refreshComposioConnections, 5000);
25848
+ setTimeout(refreshComposioConnections, 15000);
25849
+ setTimeout(refreshComposioConnections, 30000);
25850
+ } else {
25851
+ toast('Connected ' + slug, 'success');
25852
+ refreshComposioConnections();
25853
+ }
25854
+ } catch (e) { toast('Connect failed: ' + e, 'error'); }
25855
+ }
25856
+
25857
+ async function disconnectComposio(slug, connectionId) {
25858
+ if (!confirm('Disconnect this ' + slug + ' account?')) return;
25859
+ try {
25860
+ var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/disconnect', {
25861
+ method: 'POST',
25862
+ credentials: 'same-origin',
25863
+ headers: { 'content-type': 'application/json' },
25864
+ body: JSON.stringify({ connectionId: connectionId }),
25865
+ });
25866
+ if (!res.ok) {
25867
+ var d = await res.json().catch(function() { return {}; });
25868
+ toast('Disconnect failed: ' + (d.error || res.status), 'error');
25869
+ return;
25870
+ }
25871
+ toast('Disconnected', 'success');
25872
+ refreshComposioConnections();
25873
+ } catch (e) { toast('Disconnect failed: ' + e, 'error'); }
25874
+ }
25875
+
25577
25876
  async function refreshMcpServers() {
25578
25877
  var container = document.getElementById('mcp-servers-list');
25579
25878
  if (!container) return;
package/dist/cli/setup.js CHANGED
@@ -176,6 +176,18 @@ const FEATURES = [
176
176
  },
177
177
  ],
178
178
  },
179
+ {
180
+ value: 'composio',
181
+ name: 'Composio (1000+ services — Gmail, Slack, Notion, GitHub, …)',
182
+ credentials: [
183
+ {
184
+ key: 'COMPOSIO_API_KEY',
185
+ label: 'Composio API key',
186
+ help: `Sign up at ${CYAN}https://app.composio.dev${DIM} (free tier) and copy your key from Developers > API Keys.\n After setup, manage connections in the dashboard: Settings > Integrations.`,
187
+ masked: true,
188
+ },
189
+ ],
190
+ },
179
191
  {
180
192
  value: 'outlook',
181
193
  name: 'Outlook (Microsoft Graph — email + calendar)',
@@ -598,6 +610,7 @@ export async function runSetup() {
598
610
  { header: 'Voice', keys: ['GROQ_API_KEY', 'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE_ID'] },
599
611
  { header: 'Video', keys: ['GOOGLE_API_KEY'] },
600
612
  { header: 'Outlook (Microsoft Graph)', keys: ['MS_TENANT_ID', 'MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'MS_USER_EMAIL'] },
613
+ { header: 'Composio (1000+ services broker)', keys: ['COMPOSIO_API_KEY', 'COMPOSIO_USER_ID'] },
601
614
  { header: 'Workspace', keys: ['WORKSPACE_DIRS'] },
602
615
  { header: 'Security', keys: ['ALLOW_ALL_USERS'] },
603
616
  ];
@@ -189,6 +189,19 @@ export const INTEGRATIONS = [
189
189
  { envVar: 'GOOGLE_API_KEY', label: 'Gemini API key', required: true, docUrl: 'https://aistudio.google.com/apikey', persistent: true },
190
190
  ],
191
191
  },
192
+ // ── Multi-service broker ──────────────────────────────────────────
193
+ {
194
+ slug: 'composio',
195
+ label: 'Composio',
196
+ description: 'OAuth + tool execution for 1000+ services (Gmail, Slack, Notion, GitHub, …). Connect toolkits in Settings → Integrations.',
197
+ kind: 'api-key',
198
+ docUrl: 'https://app.composio.dev/developers',
199
+ capabilities: ['Gmail', 'Google Calendar/Drive/Docs', 'Slack', 'Notion', 'GitHub', 'Linear', 'HubSpot', 'Salesforce', 'and 1000+ more'],
200
+ requirements: [
201
+ { envVar: 'COMPOSIO_API_KEY', label: 'Composio API key', required: true, docUrl: 'https://app.composio.dev/developers', persistent: true },
202
+ { envVar: 'COMPOSIO_USER_ID', label: 'User ID for Composio connections (defaults to clementine-default)', required: false, persistent: true },
203
+ ],
204
+ },
192
205
  // ── Payments ──────────────────────────────────────────────────────
193
206
  {
194
207
  slug: 'stripe',
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Composio integration — client + connection management.
3
+ *
4
+ * Composio brokers OAuth and tool execution for 1000+ third-party services
5
+ * (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
6
+ * Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
7
+ * automatically. Clementine never sees the credentials.
8
+ *
9
+ * This module owns everything except the per-toolkit MCP server construction
10
+ * (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
11
+ *
12
+ * Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
13
+ * returns an empty result, never throws.
14
+ */
15
+ import { Composio } from '@composio/core';
16
+ import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
17
+ export type ToolkitAuthMode = 'managed' | 'byo';
18
+ export interface CuratedToolkit {
19
+ slug: string;
20
+ displayName: string;
21
+ /** "managed" = Composio hosts the OAuth app — click Connect, it works.
22
+ * "byo" = User must register their own OAuth app on the toolkit's
23
+ * dev portal and add it as an Auth Config in Composio first. */
24
+ authMode: ToolkitAuthMode;
25
+ }
26
+ export declare const CURATED_TOOLKITS: CuratedToolkit[];
27
+ export declare function getComposio(): Composio<ClaudeAgentSDKProvider> | null;
28
+ export declare function isComposioEnabled(): boolean;
29
+ /**
30
+ * Discard the cached client + identity cache so the next call to getComposio()
31
+ * picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
32
+ * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
33
+ */
34
+ export declare function resetComposioClient(): void;
35
+ export declare function clementineUserId(): string;
36
+ export declare function displayNameFor(slug: string): string;
37
+ export interface ConnectedToolkit {
38
+ slug: string;
39
+ connectionId: string;
40
+ status: string;
41
+ alias?: string;
42
+ accountLabel?: string;
43
+ accountEmail?: string;
44
+ accountName?: string;
45
+ accountAvatarUrl?: string;
46
+ createdAt?: string;
47
+ }
48
+ export declare function listConnectedToolkits(): Promise<ConnectedToolkit[]>;
49
+ export interface ToolkitMeta {
50
+ slug: string;
51
+ name: string;
52
+ logo?: string;
53
+ description?: string;
54
+ toolsCount?: number;
55
+ }
56
+ export declare function listToolkitMeta(): Promise<Map<string, ToolkitMeta>>;
57
+ export declare function listToolkitSlugsWithAuthConfig(): Promise<Set<string>>;
58
+ export declare class ComposioNeedsAuthConfigError extends Error {
59
+ readonly slug: string;
60
+ readonly underlying: string;
61
+ constructor(slug: string, underlying: string);
62
+ }
63
+ export declare function authorizeToolkit(slug: string, opts?: {
64
+ callbackUrl?: string;
65
+ alias?: string;
66
+ }): Promise<{
67
+ redirectUrl: string | null;
68
+ connectionId: string;
69
+ }>;
70
+ export declare function disconnectToolkit(connectionId: string): Promise<void>;
71
+ export declare function renameConnection(connectionId: string, alias: string): Promise<void>;
72
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Composio integration — client + connection management.
3
+ *
4
+ * Composio brokers OAuth and tool execution for 1000+ third-party services
5
+ * (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
6
+ * Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
7
+ * automatically. Clementine never sees the credentials.
8
+ *
9
+ * This module owns everything except the per-toolkit MCP server construction
10
+ * (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
11
+ *
12
+ * Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
13
+ * returns an empty result, never throws.
14
+ */
15
+ import { Composio } from '@composio/core';
16
+ import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
17
+ import pino from 'pino';
18
+ const logger = pino({ name: 'clementine.composio' });
19
+ // Curated set surfaced in the dashboard. Composio exposes 1000+ — rendering
20
+ // them all is noisy. Users can still connect anything by editing this list.
21
+ export const CURATED_TOOLKITS = [
22
+ { slug: 'gmail', displayName: 'Gmail', authMode: 'managed' },
23
+ { slug: 'googlecalendar', displayName: 'Google Calendar', authMode: 'managed' },
24
+ { slug: 'googledrive', displayName: 'Google Drive', authMode: 'managed' },
25
+ { slug: 'googlesheets', displayName: 'Google Sheets', authMode: 'managed' },
26
+ { slug: 'googledocs', displayName: 'Google Docs', authMode: 'managed' },
27
+ { slug: 'slack', displayName: 'Slack', authMode: 'managed' },
28
+ { slug: 'github', displayName: 'GitHub', authMode: 'managed' },
29
+ { slug: 'linear', displayName: 'Linear', authMode: 'managed' },
30
+ { slug: 'notion', displayName: 'Notion', authMode: 'managed' },
31
+ { slug: 'hubspot', displayName: 'HubSpot', authMode: 'managed' },
32
+ { slug: 'salesforce', displayName: 'Salesforce', authMode: 'managed' },
33
+ { slug: 'discord', displayName: 'Discord', authMode: 'managed' },
34
+ { slug: 'trello', displayName: 'Trello', authMode: 'managed' },
35
+ { slug: 'asana', displayName: 'Asana', authMode: 'managed' },
36
+ { slug: 'jira', displayName: 'Jira', authMode: 'managed' },
37
+ { slug: 'airtable', displayName: 'Airtable', authMode: 'managed' },
38
+ { slug: 'figma', displayName: 'Figma', authMode: 'managed' },
39
+ { slug: 'dropbox', displayName: 'Dropbox', authMode: 'managed' },
40
+ { slug: 'stripe', displayName: 'Stripe', authMode: 'managed' },
41
+ { slug: 'supabase', displayName: 'Supabase', authMode: 'managed' },
42
+ { slug: 'linkedin', displayName: 'LinkedIn', authMode: 'managed' },
43
+ { slug: 'twitter', displayName: 'Twitter / X', authMode: 'byo' },
44
+ ];
45
+ const DISPLAY_NAME_BY_SLUG = new Map(CURATED_TOOLKITS.map(t => [t.slug, t.displayName]));
46
+ let singleton = null;
47
+ export function getComposio() {
48
+ if (singleton)
49
+ return singleton;
50
+ const apiKey = process.env.COMPOSIO_API_KEY;
51
+ if (!apiKey)
52
+ return null;
53
+ singleton = new Composio({
54
+ apiKey,
55
+ provider: new ClaudeAgentSDKProvider(),
56
+ });
57
+ return singleton;
58
+ }
59
+ export function isComposioEnabled() {
60
+ return Boolean(process.env.COMPOSIO_API_KEY);
61
+ }
62
+ /**
63
+ * Discard the cached client + identity cache so the next call to getComposio()
64
+ * picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
65
+ * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
66
+ */
67
+ export function resetComposioClient() {
68
+ singleton = null;
69
+ identityCache.clear();
70
+ toolkitMetaCache = null;
71
+ }
72
+ // Single-tenant by default. All connections are keyed under COMPOSIO_USER_ID;
73
+ // override if the same Composio account is shared with another tool.
74
+ export function clementineUserId() {
75
+ return process.env.COMPOSIO_USER_ID ?? 'clementine-default';
76
+ }
77
+ export function displayNameFor(slug) {
78
+ return DISPLAY_NAME_BY_SLUG.get(slug) ?? humanize(slug);
79
+ }
80
+ function humanize(slug) {
81
+ return slug
82
+ .split(/[-_]/g)
83
+ .filter(Boolean)
84
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
85
+ .join(' ');
86
+ }
87
+ function str(v) {
88
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
89
+ }
90
+ function decodeJwtPayload(jwt) {
91
+ try {
92
+ const parts = jwt.split('.');
93
+ if (parts.length < 2)
94
+ return null;
95
+ const payload = parts[1];
96
+ const padded = payload + '==='.slice((payload.length + 3) % 4);
97
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/');
98
+ return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ // Composio's connected-account state is shaped per toolkit. Pull whatever
105
+ // human identity we can find — OAuth id_tokens (Google et al.) carry
106
+ // email/name/picture; other toolkits stash shop, subdomain, account_id.
107
+ function extractAccountIdentity(state, data) {
108
+ const s = (state && typeof state === 'object' ? state : {}) ?? {};
109
+ const d = (data && typeof data === 'object' ? data : {}) ?? {};
110
+ const out = {};
111
+ const idToken = str(s.id_token) ?? str(d.id_token);
112
+ if (idToken) {
113
+ const payload = decodeJwtPayload(idToken);
114
+ if (payload) {
115
+ out.email = str(payload.email);
116
+ out.name = str(payload.name) ?? str(payload.given_name);
117
+ out.avatarUrl = str(payload.picture);
118
+ }
119
+ }
120
+ for (const src of [d, s]) {
121
+ const profile = (src.user_info && typeof src.user_info === 'object' ? src.user_info : null) ??
122
+ (src.profile && typeof src.profile === 'object' ? src.profile : null);
123
+ if (profile) {
124
+ out.email = out.email ?? str(profile.email);
125
+ out.name = out.name ?? str(profile.name) ?? str(profile.display_name);
126
+ out.avatarUrl = out.avatarUrl ?? str(profile.picture) ?? str(profile.avatar_url);
127
+ }
128
+ out.email = out.email ?? str(src.email);
129
+ out.name = out.name ?? str(src.name) ?? str(src.display_name);
130
+ out.avatarUrl = out.avatarUrl ?? str(src.avatar_url) ?? str(src.picture);
131
+ }
132
+ const fallback = str(s.shop) ??
133
+ str(s.subdomain) ??
134
+ str(s.domain) ??
135
+ str(s.account_id) ??
136
+ str(s.site_name) ??
137
+ str(d.shop) ??
138
+ str(d.subdomain);
139
+ out.label = out.email ?? out.name ?? fallback;
140
+ return out;
141
+ }
142
+ function genericProfileParse(d) {
143
+ const first = (...candidates) => {
144
+ for (const c of candidates)
145
+ if (typeof c === 'string' && c.trim())
146
+ return c.trim();
147
+ return undefined;
148
+ };
149
+ const nested = (key) => (d[key] && typeof d[key] === 'object' ? d[key] : {});
150
+ const viewer = nested('viewer');
151
+ const user = nested('user');
152
+ const team = nested('team');
153
+ const profile = nested('profile');
154
+ const email = first(d.email, user.email, viewer.email, profile.email);
155
+ const name = first(d.name, d.login, d.display_name, user.name, viewer.name, profile.name, team.name);
156
+ const avatar = first(d.avatar_url, d.avatarUrl, d.picture, user.avatar_url, viewer.avatarUrl, profile.image);
157
+ return { email, name, avatarUrl: avatar, label: email ?? name };
158
+ }
159
+ const WHOAMI_BY_TOOLKIT = {
160
+ gmail: {
161
+ tool: 'GMAIL_GET_PROFILE',
162
+ arguments: { user_id: 'me' },
163
+ parse: d => {
164
+ const email = typeof d.emailAddress === 'string' ? d.emailAddress : undefined;
165
+ return { email, label: email };
166
+ },
167
+ },
168
+ github: { tool: 'GITHUB_GET_THE_AUTHENTICATED_USER', arguments: {}, parse: genericProfileParse },
169
+ linear: { tool: 'LINEAR_GET_CURRENT_USER', arguments: {}, parse: genericProfileParse },
170
+ notion: { tool: 'NOTION_GET_ABOUT_ME', arguments: {}, parse: genericProfileParse },
171
+ slack: { tool: 'SLACK_FETCH_TEAM_INFO', arguments: {}, parse: genericProfileParse },
172
+ hubspot: { tool: 'HUBSPOT_GET_ACCOUNT_INFO', arguments: {}, parse: genericProfileParse },
173
+ stripe: { tool: 'STRIPE_GET_ACCOUNT', arguments: {}, parse: genericProfileParse },
174
+ };
175
+ const identityCache = new Map();
176
+ const IDENTITY_TTL_MS = 15 * 60 * 1000;
177
+ async function fetchToolkitIdentity(composio, slug, connectedAccountId) {
178
+ const spec = WHOAMI_BY_TOOLKIT[slug];
179
+ if (!spec)
180
+ return {};
181
+ try {
182
+ const result = await composio.tools.execute(spec.tool, {
183
+ userId: clementineUserId(),
184
+ // Without this, Composio picks the user's *default* connection for the
185
+ // toolkit, so multiple Gmail accounts end up labeled with the same email.
186
+ ...(connectedAccountId ? { connectedAccountId } : {}),
187
+ arguments: spec.arguments,
188
+ // Composio requires either a pinned toolkit version or this flag.
189
+ // Skipping pin so a toolkit bump doesn't break identity silently —
190
+ // misses fall through to fallback chain below.
191
+ dangerouslySkipVersionCheck: true,
192
+ });
193
+ if (!result.successful || !result.data)
194
+ return {};
195
+ return spec.parse(result.data);
196
+ }
197
+ catch (err) {
198
+ logger.warn({ err, slug }, 'whoami fetch failed');
199
+ return {};
200
+ }
201
+ }
202
+ async function getIdentityFor(composio, id, slug, seed) {
203
+ if (seed.label)
204
+ return seed;
205
+ const cached = identityCache.get(id);
206
+ if (cached && Date.now() - cached.at < IDENTITY_TTL_MS)
207
+ return cached.identity;
208
+ let identity = {};
209
+ try {
210
+ const full = await composio.connectedAccounts.get(id);
211
+ identity = extractAccountIdentity(full.state, full.data);
212
+ }
213
+ catch (err) {
214
+ logger.warn({ err, id }, 'failed to fetch full connection for identity');
215
+ }
216
+ if (!identity.label) {
217
+ const whoami = await fetchToolkitIdentity(composio, slug, id);
218
+ if (whoami.label)
219
+ identity = { ...identity, ...whoami };
220
+ }
221
+ identityCache.set(id, { at: Date.now(), identity });
222
+ return identity;
223
+ }
224
+ export async function listConnectedToolkits() {
225
+ const composio = getComposio();
226
+ if (!composio)
227
+ return [];
228
+ try {
229
+ const resp = await composio.connectedAccounts.list({ userIds: [clementineUserId()] });
230
+ const enriched = await Promise.all(resp.items.map(async (it) => {
231
+ const seed = extractAccountIdentity(it.state, it.data);
232
+ const identity = it.status === 'ACTIVE'
233
+ ? await getIdentityFor(composio, it.id, it.toolkit.slug, seed)
234
+ : seed;
235
+ return {
236
+ slug: it.toolkit.slug,
237
+ connectionId: it.id,
238
+ status: it.status,
239
+ alias: it.alias ?? undefined,
240
+ accountLabel: identity.label,
241
+ accountEmail: identity.email,
242
+ accountName: identity.name,
243
+ accountAvatarUrl: identity.avatarUrl,
244
+ createdAt: it.createdAt,
245
+ };
246
+ }));
247
+ return enriched;
248
+ }
249
+ catch (err) {
250
+ logger.error({ err }, 'listConnectedToolkits failed');
251
+ return [];
252
+ }
253
+ }
254
+ let toolkitMetaCache = null;
255
+ async function fetchAllToolkitMeta() {
256
+ const composio = getComposio();
257
+ if (!composio)
258
+ return new Map();
259
+ const out = new Map();
260
+ try {
261
+ const resp = await composio.toolkits.get({ limit: 500 });
262
+ const items = Array.isArray(resp) ? resp : (resp.items ?? []);
263
+ for (const it of items) {
264
+ out.set(it.slug, {
265
+ slug: it.slug,
266
+ name: it.name,
267
+ logo: it.meta?.logo,
268
+ description: it.meta?.description,
269
+ toolsCount: it.meta?.toolsCount,
270
+ });
271
+ }
272
+ // Backfill curated toolkits the list endpoint omitted (e.g. MCP-only ones).
273
+ await Promise.all(CURATED_TOOLKITS.filter(t => !out.has(t.slug)).map(async (t) => {
274
+ try {
275
+ const full = (await composio.toolkits.get(t.slug));
276
+ out.set(full.slug, {
277
+ slug: full.slug,
278
+ name: full.name,
279
+ logo: full.meta?.logo,
280
+ description: full.meta?.description,
281
+ toolsCount: full.meta?.toolsCount,
282
+ });
283
+ }
284
+ catch (err) {
285
+ logger.debug({ err, slug: t.slug }, 'meta backfill failed');
286
+ }
287
+ }));
288
+ }
289
+ catch (err) {
290
+ logger.error({ err }, 'fetchAllToolkitMeta failed');
291
+ }
292
+ return out;
293
+ }
294
+ export async function listToolkitMeta() {
295
+ if (!toolkitMetaCache) {
296
+ toolkitMetaCache = fetchAllToolkitMeta().catch(err => {
297
+ logger.error({ err }, 'listToolkitMeta failed');
298
+ toolkitMetaCache = null;
299
+ return new Map();
300
+ });
301
+ }
302
+ return toolkitMetaCache;
303
+ }
304
+ export async function listToolkitSlugsWithAuthConfig() {
305
+ const composio = getComposio();
306
+ if (!composio)
307
+ return new Set();
308
+ try {
309
+ const resp = await composio.authConfigs.list({ limit: 200 });
310
+ return new Set(resp.items.map(it => it.toolkit.slug));
311
+ }
312
+ catch (err) {
313
+ logger.error({ err }, 'listToolkitSlugsWithAuthConfig failed');
314
+ return new Set();
315
+ }
316
+ }
317
+ // ── Authorize / disconnect ─────────────────────────────────────────────
318
+ export class ComposioNeedsAuthConfigError extends Error {
319
+ slug;
320
+ underlying;
321
+ constructor(slug, underlying) {
322
+ super(`Toolkit "${slug}" needs an auth config — Composio doesn't host a managed OAuth app for it. ` +
323
+ `Add it via the Composio Dashboard: Toolkits → search ${slug} → Add to project → paste your OAuth credentials. ` +
324
+ `https://platform.composio.dev/auth-configs`);
325
+ this.slug = slug;
326
+ this.underlying = underlying;
327
+ this.name = 'ComposioNeedsAuthConfigError';
328
+ }
329
+ }
330
+ export async function authorizeToolkit(slug, opts) {
331
+ const composio = getComposio();
332
+ if (!composio)
333
+ throw new Error('COMPOSIO_API_KEY not set');
334
+ // 1. Find or create an auth config. session.authorize() doesn't auto-create
335
+ // so we have to pass authConfigId explicitly to connectedAccounts.initiate.
336
+ let authConfigId;
337
+ const existingConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
338
+ if (existingConfig) {
339
+ authConfigId = existingConfig.id;
340
+ }
341
+ else {
342
+ try {
343
+ const created = await composio.authConfigs.create(slug, {
344
+ type: 'use_composio_managed_auth',
345
+ name: `${displayNameFor(slug)} Auth Config`,
346
+ });
347
+ authConfigId = created.id;
348
+ }
349
+ catch (err) {
350
+ // 400 → Composio doesn't host a managed OAuth app for this toolkit;
351
+ // user must register their own via the Composio dashboard.
352
+ const status = err?.status;
353
+ if (status === 400) {
354
+ throw new ComposioNeedsAuthConfigError(slug, String(err));
355
+ }
356
+ throw err;
357
+ }
358
+ }
359
+ // 2. Initiate the connection. allowMultiple if there's already an active
360
+ // connection so we add another account instead of replacing.
361
+ const existing = (await listConnectedToolkits()).filter(c => c.slug === slug && c.status === 'ACTIVE');
362
+ const conn = await composio.connectedAccounts.initiate(clementineUserId(), authConfigId, {
363
+ ...(existing.length > 0 ? { allowMultiple: true } : {}),
364
+ ...(opts?.callbackUrl ? { callbackUrl: opts.callbackUrl } : {}),
365
+ ...(opts?.alias ? { alias: opts.alias } : {}),
366
+ });
367
+ return { redirectUrl: conn.redirectUrl ?? null, connectionId: conn.id };
368
+ }
369
+ export async function disconnectToolkit(connectionId) {
370
+ const composio = getComposio();
371
+ if (!composio)
372
+ throw new Error('COMPOSIO_API_KEY not set');
373
+ await composio.connectedAccounts.delete(connectionId);
374
+ identityCache.delete(connectionId);
375
+ }
376
+ export async function renameConnection(connectionId, alias) {
377
+ const composio = getComposio();
378
+ if (!composio)
379
+ throw new Error('COMPOSIO_API_KEY not set');
380
+ // The Composio high-level wrapper only exposes status updates; alias
381
+ // rename lives on the raw client's PATCH endpoint, which is marked
382
+ // protected. Bridge through `as any` — this is a small, well-scoped escape
383
+ // hatch and the alternative (bypassing the wrapper entirely) loses retry
384
+ // and auth handling.
385
+ await composio.client.connectedAccounts.patch(connectionId, { alias });
386
+ }
387
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
3
+ * servers that the Claude Agent SDK's `query()` can spawn directly.
4
+ *
5
+ * Why per-toolkit servers (instead of one big "composio" server)?
6
+ * - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
7
+ * a Calendar-only sub-agent shouldn't drag in 200 tools across 20
8
+ * toolkits.
9
+ * - Tool-name namespacing. Each MCP server registers its tools under its
10
+ * own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
11
+ * `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
12
+ * - Concurrency. Composio's TS provider opens one session per server, so
13
+ * having one server per toolkit lets unrelated toolkits load in parallel.
14
+ *
15
+ * Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
16
+ * always works — Composio is purely additive.
17
+ */
18
+ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
19
+ /**
20
+ * Build SDK MCP server configs for the given toolkit slugs (or all active
21
+ * connected toolkits when omitted). Each toolkit becomes one MCP server.
22
+ */
23
+ export declare function buildComposioMcpServers(slugs?: string[]): Promise<Record<string, McpSdkServerConfigWithInstance>>;
24
+ //# sourceMappingURL=mcp-bridge.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
3
+ * servers that the Claude Agent SDK's `query()` can spawn directly.
4
+ *
5
+ * Why per-toolkit servers (instead of one big "composio" server)?
6
+ * - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
7
+ * a Calendar-only sub-agent shouldn't drag in 200 tools across 20
8
+ * toolkits.
9
+ * - Tool-name namespacing. Each MCP server registers its tools under its
10
+ * own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
11
+ * `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
12
+ * - Concurrency. Composio's TS provider opens one session per server, so
13
+ * having one server per toolkit lets unrelated toolkits load in parallel.
14
+ *
15
+ * Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
16
+ * always works — Composio is purely additive.
17
+ */
18
+ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
19
+ import pino from 'pino';
20
+ import { clementineUserId, getComposio, listConnectedToolkits, } from './client.js';
21
+ const logger = pino({ name: 'clementine.composio.mcp' });
22
+ /**
23
+ * Build SDK MCP server configs for the given toolkit slugs (or all active
24
+ * connected toolkits when omitted). Each toolkit becomes one MCP server.
25
+ */
26
+ export async function buildComposioMcpServers(slugs) {
27
+ const composio = getComposio();
28
+ if (!composio)
29
+ return {};
30
+ const connected = await listConnectedToolkits();
31
+ const activeSlugs = new Set(connected.filter(c => c.status === 'ACTIVE').map(c => c.slug));
32
+ const targetSlugs = slugs?.length
33
+ ? slugs.filter(s => activeSlugs.has(s))
34
+ : [...activeSlugs];
35
+ if (targetSlugs.length === 0)
36
+ return {};
37
+ const out = {};
38
+ // Build serially to avoid hammering Composio with parallel session opens
39
+ // on every agent query. Sessions are cached upstream by Composio's SDK,
40
+ // so repeat calls within a process are cheap after the first hit.
41
+ for (const slug of targetSlugs) {
42
+ try {
43
+ out[slug] = await buildOne(composio, slug, connected);
44
+ }
45
+ catch (err) {
46
+ logger.warn({ err, slug }, 'failed to build Composio MCP server — skipping');
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ async function buildOne(composio, slug, connected) {
52
+ // If 2+ active connections for this toolkit, force Composio to require
53
+ // explicit account selection per tool call — otherwise it silently picks
54
+ // the default (newest) account.
55
+ const activeCount = connected.filter(c => c.slug === slug && c.status === 'ACTIVE').length;
56
+ // Look up auth config explicitly. Without this, composio.create() tries to
57
+ // auto-create one and 400s for BYO toolkits (Twitter etc.) that don't have
58
+ // a managed OAuth app available.
59
+ const authConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
60
+ const session = await composio.create(clementineUserId(), {
61
+ toolkits: [slug],
62
+ manageConnections: false,
63
+ ...(authConfig ? { authConfigs: { [slug]: authConfig.id } } : {}),
64
+ ...(activeCount >= 2
65
+ ? { multiAccount: { enable: true, requireExplicitSelection: true } }
66
+ : {}),
67
+ });
68
+ const tools = await session.tools();
69
+ return createSdkMcpServer({
70
+ name: slug,
71
+ version: '0.1.0',
72
+ tools,
73
+ });
74
+ }
75
+ //# sourceMappingURL=mcp-bridge.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,8 @@
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "^0.2.119",
25
25
  "@anthropic-ai/sdk": "^0.91.0",
26
+ "@composio/claude-agent-sdk": "^0.8.1",
27
+ "@composio/core": "^0.8.1",
26
28
  "@inquirer/prompts": "^7.0.0",
27
29
  "@modelcontextprotocol/sdk": "^1.29.0",
28
30
  "@slack/bolt": "^4.2.0",