From 6e0be028c9d4e1c8c3f465f7c15ecc2d777b9887 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Mon, 5 Oct 2026 11:49:33 +0100 Subject: [PATCH 1/2] supabase integration --- app/action/action_set.py | 67 +- app/agent_base.py | 5 + app/config.py | 9 + .../integrations/integration_management.py | 5 +- .../pages/Settings/IntegrationsSettings.tsx | 9 +- craftos_integrations/README.md | 5 +- craftos_integrations/core/system.py | 24 + craftos_integrations/providers/__init__.py | 2 + .../providers/supabase/GUIDANCE.md | 136 ++ .../providers/supabase/INTEGRATION.md | 167 ++ .../providers/supabase/__init__.py | 3 + .../providers/supabase/client.py | 1949 +++++++++++++++++ .../providers/supabase/operations.py | 1344 ++++++++++++ .../providers/supabase/provider.py | 420 ++++ scripts/smoke_supabase.py | 383 ++++ tests/integrations/test_provider_listeners.py | 4 +- .../integrations/test_supabase_conformance.py | 716 ++++++ tests/integrations/test_system.py | 56 + tests/test_action_set_catalog.py | 60 + 19 files changed, 5348 insertions(+), 16 deletions(-) create mode 100644 craftos_integrations/providers/supabase/GUIDANCE.md create mode 100644 craftos_integrations/providers/supabase/INTEGRATION.md create mode 100644 craftos_integrations/providers/supabase/__init__.py create mode 100644 craftos_integrations/providers/supabase/client.py create mode 100644 craftos_integrations/providers/supabase/operations.py create mode 100644 craftos_integrations/providers/supabase/provider.py create mode 100644 scripts/smoke_supabase.py create mode 100644 tests/integrations/test_supabase_conformance.py create mode 100644 tests/test_action_set_catalog.py diff --git a/app/action/action_set.py b/app/action/action_set.py index 67430c067..ac0745757 100644 --- a/app/action/action_set.py +++ b/app/action/action_set.py @@ -27,6 +27,56 @@ } +_MAX_NOUNS = 6 + + +def _umbrella_of(set_name: str, all_sets: Dict[str, List[str]]) -> Optional[str]: + """Longest other set name that prefixes this one (``supabase`` for + ``supabase_storage``) — the integration's shortlist set.""" + best = None + for other in all_sets: + if other != set_name and set_name.startswith(other + "_"): + if best is None or len(other) > len(best): + best = other + return best + + +def describe_set( + set_name: str, actions: List[str], all_sets: Dict[str, List[str]] +) -> str: + """Catalog line for a set with no hand-written description. + + The agent picks sets from this catalog, so the line must say what is + inside. Integration sets are named by integration and noun + (``supabase_storage``) and their actions by verb + integration + noun + (``delete_supabase_bucket``); listing the nouns tells the agent where + an action lives without spending tokens on every action name. + """ + sub_sets = sorted(s for s in all_sets if s.startswith(set_name + "_")) + if sub_sets: + return ( + f"shortlist of the most-used {set_name} actions ({len(actions)}); " + f"not everything — load {set_name}_* sets for the rest" + ) + + umbrella = _umbrella_of(set_name, all_sets) + strip = set(umbrella.split("_")) if umbrella else set() + shown_as: Dict[str, str] = {} # singular key -> display form, in order + for action in actions: + tokens = [t for t in action.split("_") if t not in strip] + noun = "_".join(tokens[1:]) if len(tokens) > 1 else action + key = noun[:-2] if noun.endswith(("ches", "shes", "xes", "sses")) else noun.rstrip("s") + if not key: + continue + # Keep first-seen order, but prefer the plural form for display. + if key not in shown_as or (noun.endswith("s") and not shown_as[key].endswith("s")): + shown_as[key] = noun + nouns = list(shown_as.values()) + shown = ", ".join(nouns[:_MAX_NOUNS]) + more = ", …" if len(nouns) > _MAX_NOUNS else "" + return f"{len(actions)} actions: {shown}{more}" + + class ActionSetManager: """ Singleton managing action set compilation from the ActionRegistry. @@ -134,7 +184,7 @@ def list_all_sets(self) -> Dict[str, str]: from agent_core import registry_instance, PLATFORM_ALL current_platform = platform_lib.system().lower() - discovered_sets: Dict[str, str] = {} + members: Dict[str, List[str]] = {} # Scan all registered actions to find unique set names for action_name, platform_impls in registry_instance._registry.items(): @@ -147,14 +197,13 @@ def list_all_sets(self) -> Dict[str, str]: action_sets = getattr(impl.metadata, "action_sets", []) for set_name in action_sets: - if set_name not in discovered_sets: - # Use default description if known, otherwise generate one - desc = DEFAULT_SET_DESCRIPTIONS.get( - set_name, f"Custom action set: {set_name}" - ) - discovered_sets[set_name] = desc - - return discovered_sets + members.setdefault(set_name, []).append(action_name) + + return { + set_name: DEFAULT_SET_DESCRIPTIONS.get(set_name) + or describe_set(set_name, actions, members) + for set_name, actions in members.items() + } def get_set_description(self, set_name: str) -> Optional[str]: """ diff --git a/app/agent_base.py b/app/agent_base.py index 96c2aea46..b7f4f3e6e 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -49,6 +49,8 @@ NOTION_SHARED_CLIENT_SECRET, HUBSPOT_SHARED_CLIENT_ID, HUBSPOT_SHARED_CLIENT_SECRET, + SUPABASE_SHARED_CLIENT_ID, + SUPABASE_SHARED_CLIENT_SECRET, SLACK_SHARED_CLIENT_ID, SLACK_SHARED_CLIENT_SECRET, TELEGRAM_SHARED_BOT_TOKEN, @@ -3542,6 +3544,9 @@ async def _initialize_external_libraries(self) -> None: # HubSpot (only used by the `invite` OAuth path; Private App token login needs nothing) "HUBSPOT_SHARED_CLIENT_ID": HUBSPOT_SHARED_CLIENT_ID, "HUBSPOT_SHARED_CLIENT_SECRET": HUBSPOT_SHARED_CLIENT_SECRET, + # Supabase (only used by the OAuth path; personal-access-token login needs nothing) + "SUPABASE_SHARED_CLIENT_ID": SUPABASE_SHARED_CLIENT_ID, + "SUPABASE_SHARED_CLIENT_SECRET": SUPABASE_SHARED_CLIENT_SECRET, # Slack (only used by the `invite` OAuth path) "SLACK_SHARED_CLIENT_ID": SLACK_SHARED_CLIENT_ID, "SLACK_SHARED_CLIENT_SECRET": SLACK_SHARED_CLIENT_SECRET, diff --git a/app/config.py b/app/config.py index 5d59e1989..092f74297 100644 --- a/app/config.py +++ b/app/config.py @@ -654,3 +654,12 @@ def detect_and_save_os_language() -> str: HUBSPOT_SHARED_CLIENT_SECRET: str = get_credential( "hubspot", "client_secret", "HUBSPOT_SHARED_CLIENT_SECRET" ) + +# Supabase (confidential OAuth app + PKCE; empty until the app is registered, +# in which case the integration offers only personal-access-token login) +SUPABASE_SHARED_CLIENT_ID: str = get_credential( + "supabase", "client_id", "SUPABASE_SHARED_CLIENT_ID" +) +SUPABASE_SHARED_CLIENT_SECRET: str = get_credential( + "supabase", "client_secret", "SUPABASE_SHARED_CLIENT_SECRET" +) diff --git a/app/data/action/integrations/integration_management.py b/app/data/action/integrations/integration_management.py index 1e6182d75..fe882e338 100644 --- a/app/data/action/integrations/integration_management.py +++ b/app/data/action/integrations/integration_management.py @@ -115,7 +115,7 @@ def list_available_integrations(input_data: dict) -> dict: "description": ( "The integration to connect, using its exact id. Valid values: slack, " "discord, telegram, whatsapp, whatsapp_business, notion, linkedin, " - "posthog, and " + "posthog, supabase, and " "the Google Workspace apps as SEPARATE ids — gmail, google_drive, " "google_docs, google_calendar, google_youtube (there is no single " "'google' integration). Call list_available_integrations if unsure." @@ -132,7 +132,8 @@ def list_available_integrations(input_data: dict) -> dict: "whatsapp_business: {access_token, phone_number_id}, " "notion: {token}, " "posthog: {api_key, host(optional: 'us'/'eu'/self-hosted URL), " - "project_id(optional)}. " + "project_id(optional)}, " + "supabase: {access_token (personal access token, sbp_…)}. " "Leave empty for OAuth or interactive (QR code) flows." ), "example": {"bot_token": "123456:ABC-DEF"}, diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx index 005a08f76..429bc4737 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx @@ -31,6 +31,7 @@ import { YouTube, MicrosoftOutlook, PostHog, + Supabase, } from '@ridemountainpig/svgl-react' import { useTranslation } from 'react-i18next' import { Button, Badge, ConfirmModal } from '../../components/ui' @@ -98,6 +99,7 @@ const SVGL_BY_ID: Record = { google_youtube: YouTube, outlook: MicrosoftOutlook, posthog: PostHog, + supabase: Supabase, } // Integration icon component. Lookup order: @@ -1592,6 +1594,11 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool : isProblem(account) || account.sessionState === 'reconnecting' ? stateLabel(account) : '' + // Nickname first (staged edit wins); the identity can be an + // opaque id (e.g. a token fingerprint), so it stays in the tooltip. + const nickname = staged && account.identity in staged.aliases + ? staged.aliases[account.identity] + : account.alias return (