diff --git a/.github/scripts/sync-capabilities.js b/.github/scripts/sync-capabilities.js
new file mode 100644
index 0000000..55a92ea
--- /dev/null
+++ b/.github/scripts/sync-capabilities.js
@@ -0,0 +1,360 @@
+#!/usr/bin/env node
+
+/**
+ * Sync Capabilities Matrix
+ *
+ * This script parses specified connector documentation files and compares their
+ * stated capabilities against the capabilities matrix in capabilities.mdx.
+ * If mismatches are found, it updates the matrix and outputs a summary.
+ *
+ * Usage: node sync-capabilities.js [file1.mdx] [file2.mdx] ...
+ *
+ * Detection is based on explicit statements in connector docs:
+ * - Account provisioning: "account provisioning" or Accounts row with Provision checkmark
+ * - Account deprovisioning: "and deprovisioning" or "deprovision" without negation
+ * - Entitlement provisioning: Non-account resources with Provision checkmark
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const BATON_DIR = path.join(__dirname, '../../baton');
+const CAPABILITIES_FILE = path.join(BATON_DIR, 'capabilities.mdx');
+
+/**
+ * Parse the capabilities table from a connector doc
+ * Returns structured info about what can be provisioned
+ */
+function parseCapabilitiesTable(content) {
+ const result = {
+ hasTable: false,
+ accountsCanProvision: false,
+ entitlementsCanProvision: false,
+ resources: [],
+ };
+
+ // Find the capabilities table
+ const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<)/i);
+
+ if (!tableMatch) return result;
+
+ result.hasTable = true;
+ const tableContent = tableMatch[0];
+
+ // Parse each row
+ const rowRegex = /^\|\s*([^|]+)\s*\|([^|]*)\|([^|]*)\|/gm;
+ let match;
+
+ while ((match = rowRegex.exec(tableContent)) !== null) {
+ const resource = match[1].trim();
+ const provisionCol = match[3];
+
+ // Skip header row
+ if (resource.toLowerCase() === 'resource' || resource.startsWith(':') || resource.startsWith('-')) {
+ continue;
+ }
+
+ const canProvision = provisionCol.includes('square-check');
+ result.resources.push({ resource, canProvision });
+
+ // Check if this is accounts provisioning
+ if (/^accounts?$/i.test(resource) && canProvision) {
+ result.accountsCanProvision = true;
+ }
+
+ // Check for entitlement-type resources (non-accounts that can be provisioned)
+ if (!/^accounts?$/i.test(resource) && canProvision) {
+ result.entitlementsCanProvision = true;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Parse a connector doc file and extract capabilities
+ */
+function parseConnectorDoc(filePath) {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const fileName = path.basename(filePath, '.mdx');
+
+ // Parse the capabilities table first
+ const tableInfo = parseCapabilitiesTable(content);
+
+ const capabilities = {
+ fileName,
+ cloudHosted: false,
+ selfHosted: false,
+ provisionsEntitlements: false,
+ provisionsAccounts: false,
+ deprovisionsAccounts: false,
+ confidence: 'low',
+ };
+
+ // Check for hosting options
+ capabilities.cloudHosted = /');
+ if (caps.provisionsAccounts) icons.push('');
+ if (caps.deprovisionsAccounts) icons.push('');
+ return icons.join(' ');
+}
+
+/**
+ * Compare detected capabilities with matrix and find mismatches
+ * Only report mismatches where we have high confidence in the doc parsing
+ */
+function findMismatches(docCaps, matrixCaps) {
+ const mismatches = [];
+
+ // Only report mismatches for deprovisioning, which is more reliably detected
+ if (docCaps.deprovisionsAccounts !== matrixCaps.provisioning.deprovisions) {
+ mismatches.push({
+ field: 'deprovisioning (face-confused icon)',
+ doc: docCaps.deprovisionsAccounts,
+ matrix: matrixCaps.provisioning.deprovisions,
+ });
+ }
+
+ // Only report account provisioning if we have high confidence
+ if (docCaps.confidence === 'high' &&
+ docCaps.provisionsAccounts !== matrixCaps.provisioning.accounts) {
+ mismatches.push({
+ field: 'accounts (user icon)',
+ doc: docCaps.provisionsAccounts,
+ matrix: matrixCaps.provisioning.accounts,
+ });
+ }
+
+ // Only report entitlement provisioning if we have a capabilities table
+ if (docCaps.confidence === 'high' &&
+ docCaps.provisionsEntitlements !== matrixCaps.provisioning.entitlements) {
+ mismatches.push({
+ field: 'entitlements (key icon)',
+ doc: docCaps.provisionsEntitlements,
+ matrix: matrixCaps.provisioning.entitlements,
+ });
+ }
+
+ return mismatches;
+}
+
+/**
+ * Main function
+ */
+function main() {
+ // Get files to process from command line arguments
+ const args = process.argv.slice(2);
+
+ if (args.length === 0) {
+ console.log(JSON.stringify({
+ hasChanges: false,
+ mismatchCount: 0,
+ summary: 'No connector files specified.',
+ mismatches: [],
+ }, null, 2));
+ return;
+ }
+
+ // Read capabilities matrix
+ const matrixContent = fs.readFileSync(CAPABILITIES_FILE, 'utf-8');
+ const matrixConnectors = parseCapabilitiesMatrix(matrixContent);
+
+ const allMismatches = [];
+ let updatedContent = matrixContent;
+
+ for (const filePath of args) {
+ // Handle both relative and absolute paths
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);
+
+ if (!fs.existsSync(fullPath)) {
+ console.error(`Warning: File not found: ${fullPath}`);
+ continue;
+ }
+
+ const fileName = path.basename(fullPath, '.mdx');
+
+ // Skip non-connector files
+ if (fileName === 'capabilities' || fileName.startsWith('_') || fileName.startsWith('baton-')) {
+ continue;
+ }
+
+ // Parse the connector doc
+ const docCaps = parseConnectorDoc(fullPath);
+
+ // Find the connector in the matrix
+ const matrixEntry = matrixConnectors.get(fileName);
+
+ if (!matrixEntry) {
+ // Connector not in matrix - skip
+ continue;
+ }
+
+ // Compare capabilities
+ const mismatches = findMismatches(docCaps, matrixEntry);
+
+ if (mismatches.length > 0) {
+ allMismatches.push({
+ connector: matrixEntry.name,
+ fileName,
+ mismatches,
+ docCaps,
+ matrixEntry,
+ });
+
+ // Build the updated provisioning icons based on what the doc says
+ const updatedCaps = {
+ provisionsEntitlements: mismatches.find(m => m.field.includes('entitlements'))
+ ? docCaps.provisionsEntitlements
+ : matrixEntry.provisioning.entitlements,
+ provisionsAccounts: mismatches.find(m => m.field.includes('accounts'))
+ ? docCaps.provisionsAccounts
+ : matrixEntry.provisioning.accounts,
+ deprovisionsAccounts: mismatches.find(m => m.field.includes('deprovisioning'))
+ ? docCaps.deprovisionsAccounts
+ : matrixEntry.provisioning.deprovisions,
+ };
+
+ const newProvisioningIcons = buildProvisioningIcons(updatedCaps);
+
+ // Update the row
+ const newRow = matrixEntry.fullMatch.replace(
+ /\|([^|]*)\|([^|]*)\|$/,
+ `| ${newProvisioningIcons} |$2|`
+ );
+
+ updatedContent = updatedContent.replace(matrixEntry.fullMatch, newRow);
+ }
+ }
+
+ // Write updated content if there are changes
+ const hasChanges = allMismatches.length > 0;
+
+ if (hasChanges) {
+ fs.writeFileSync(CAPABILITIES_FILE, updatedContent);
+ }
+
+ // Build summary
+ let summary = '';
+ if (hasChanges) {
+ for (const { connector, fileName, mismatches } of allMismatches) {
+ summary += `### ${connector}\n`;
+ summary += `File: \`baton/${fileName}.mdx\`\n\n`;
+ for (const m of mismatches) {
+ const docStatus = m.doc ? 'supports' : 'does not support';
+ const matrixStatus = m.matrix ? 'shows' : 'does not show';
+ summary += `- **${m.field}**: Documentation says connector ${docStatus} this, but matrix ${matrixStatus} it\n`;
+ }
+ summary += '\n';
+ }
+ } else {
+ summary = 'No mismatches found. The capabilities matrix is in sync with the checked connector docs.';
+ }
+
+ // Output result as JSON
+ const result = {
+ hasChanges,
+ mismatchCount: allMismatches.length,
+ summary,
+ mismatches: allMismatches.map(m => ({
+ connector: m.connector,
+ fileName: m.fileName,
+ issues: m.mismatches,
+ })),
+ };
+
+ console.log(JSON.stringify(result, null, 2));
+}
+
+main();
diff --git a/.github/workflows/sync-capabilities.yml b/.github/workflows/sync-capabilities.yml
new file mode 100644
index 0000000..7cac4e5
--- /dev/null
+++ b/.github/workflows/sync-capabilities.yml
@@ -0,0 +1,87 @@
+name: Sync Capabilities Matrix
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'baton/*.mdx'
+ - '!baton/capabilities.mdx'
+ workflow_dispatch:
+ inputs:
+ connector:
+ description: 'Connector filename (without .mdx) to check, or "all" for full scan'
+ required: false
+ default: ''
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ sync-capabilities:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Get changed connector files
+ id: changed
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.connector }}" ]; then
+ if [ "${{ github.event.inputs.connector }}" = "all" ]; then
+ echo "files=$(ls baton/*.mdx | grep -v capabilities.mdx | grep -v '^baton/_' | grep -v '^baton/baton-' | tr '\n' ' ')" >> $GITHUB_OUTPUT
+ else
+ echo "files=baton/${{ github.event.inputs.connector }}.mdx" >> $GITHUB_OUTPUT
+ fi
+ else
+ # Get only changed connector files from the push
+ CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'baton/*.mdx' | grep -v capabilities.mdx | grep -v '^baton/_' | grep -v '^baton/baton-' | tr '\n' ' ')
+ echo "files=$CHANGED" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Setup Node.js
+ if: steps.changed.outputs.files != ''
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Run sync script
+ if: steps.changed.outputs.files != ''
+ id: sync
+ run: |
+ node .github/scripts/sync-capabilities.js ${{ steps.changed.outputs.files }} > sync-output.json
+ echo "has_changes=$(jq -r '.hasChanges' sync-output.json)" >> $GITHUB_OUTPUT
+ echo "summary<> $GITHUB_OUTPUT
+ jq -r '.summary' sync-output.json >> $GITHUB_OUTPUT
+ echo "EOF" >> $GITHUB_OUTPUT
+
+ - name: Create Pull Request
+ if: steps.changed.outputs.files != '' && steps.sync.outputs.has_changes == 'true'
+ uses: peter-evans/create-pull-request@v6
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: 'fix(capabilities): Auto-sync matrix from connector docs'
+ title: 'fix(capabilities): Auto-sync matrix from connector docs'
+ body: |
+ ## Summary
+
+ This PR was automatically generated by the capabilities sync workflow.
+
+ The following mismatches were detected between connector documentation and the capabilities matrix:
+
+ ${{ steps.sync.outputs.summary }}
+
+ ## Test plan
+ - [ ] Review each change to verify it matches the connector documentation
+ - [ ] Confirm the matrix renders correctly
+
+ ---
+ Generated by [sync-capabilities workflow](.github/workflows/sync-capabilities.yml)
+ branch: auto/sync-capabilities
+ delete-branch: true
+ labels: |
+ automated
+ documentation