Bonapara/twenty codex plugin (#20857)

@martmull v2.0 ;)

---------

Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
Thomas des Francs
2026-06-02 16:39:14 +02:00
committed by GitHub
parent cb744b2eeb
commit 1642be86f5
52 changed files with 5151 additions and 0 deletions
@@ -0,0 +1,255 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
const PLUGIN_JSON_PATH = path.join(PLUGIN_ROOT, '.codex-plugin', 'plugin.json');
const PACKAGE_JSON_PATH = path.join(PLUGIN_ROOT, 'package.json');
const MCP_JSON_PATH = path.join(PLUGIN_ROOT, '.mcp.json');
const MARKETPLACE_TEMPLATE_PATH = path.join(PLUGIN_ROOT, 'templates', 'marketplace.example.json');
const metadata = require('../validators/metadata');
const assets = require('../validators/assets');
const skills = require('../validators/skills');
const references = require('../validators/references');
const crossDocContracts = require('../validators/cross-doc-contracts');
const setupHelper = require('../validators/setup-helper');
const collectFailures = (assertion) => {
const failures = [];
assertion((message) => failures.push(message));
return failures;
};
const withFileMutation = (filePath, mutator, body) => {
const original = fs.readFileSync(filePath, 'utf8');
try {
fs.writeFileSync(filePath, mutator(original));
body();
} finally {
fs.writeFileSync(filePath, original);
}
};
const withJsonMutation = (filePath, mutator, body) =>
withFileMutation(
filePath,
(original) => {
const data = JSON.parse(original);
mutator(data);
return `${JSON.stringify(data, null, 2)}\n`;
},
body,
);
const withExtraFile = (filePath, contents, body) => {
try {
fs.writeFileSync(filePath, contents);
body();
} finally {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
};
// ---------------------------------------------------------------------------
// Smoke tests — every assertion should pass on the current plugin state.
// ---------------------------------------------------------------------------
test('assertJsonMetadata passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertJsonMetadata), []);
});
test('assertNoBundledMcpConfig passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertNoBundledMcpConfig), []);
});
test('assertInterfaceFields passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertInterfaceFields), []);
});
test('assertMarketplaceTemplate passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertMarketplaceTemplate), []);
});
test('assertAssets passes on current state', () => {
assert.deepStrictEqual(collectFailures(assets.assertAssets), []);
});
test('assertSkills passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertSkills), []);
});
test('assertSkillTriggerPhrases passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertSkillTriggerPhrases), []);
});
test('assertNoLegacySkillReferences passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertNoLegacySkillReferences), []);
});
test('assertReferences passes on current state', () => {
assert.deepStrictEqual(collectFailures(references.assertReferences), []);
});
test('assertHowAppsWork passes on current state', () => {
assert.deepStrictEqual(collectFailures(references.assertHowAppsWork), []);
});
test('assertTwentyMcpFormattingContract passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertTwentyMcpFormattingContract), []);
});
test('assertFrontComponentGuidance passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertFrontComponentGuidance), []);
});
test('assertCliGuidanceSplit passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertCliGuidanceSplit), []);
});
test('assertTestingGuidance passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertTestingGuidance), []);
});
test('assertSetupHelper passes on current state', () => {
assert.deepStrictEqual(collectFailures(setupHelper.assertSetupHelper), []);
});
// ---------------------------------------------------------------------------
// Negative cases — each assertion catches its targeted failure.
// ---------------------------------------------------------------------------
test('assertJsonMetadata catches version mismatch between package.json and plugin.json', () => {
withJsonMutation(PACKAGE_JSON_PATH, (pkg) => { pkg.version = '99.99.99'; }, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(
failures.some((f) => f.includes('version must match')),
`expected version-mismatch failure, got: ${failures.join('; ')}`,
);
});
});
test('assertJsonMetadata catches missing .mcp.json from package.json files', () => {
withJsonMutation(PACKAGE_JSON_PATH, (pkg) => {
pkg.files = pkg.files.filter((f) => f !== '.mcp.json');
}, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(failures.some((f) => f.includes('.mcp.json')));
});
});
test('assertJsonMetadata catches non-canonical MCP server', () => {
withJsonMutation(MCP_JSON_PATH, (mcp) => {
mcp.mcpServers['rogue-server'] = { url: 'https://example.com/mcp' };
}, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(failures.some((f) => f.includes('twenty-docs')));
});
});
test('assertNoBundledMcpConfig catches a bundled .app.json', () => {
const stub = path.join(PLUGIN_ROOT, '.app.json');
withExtraFile(stub, '{}', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('app declarations must not be shipped')));
});
});
test('assertNoBundledMcpConfig catches a non-placeholder URL', () => {
const stub = path.join(PLUGIN_ROOT, 'scratch-url-check.md');
withExtraFile(stub, 'see https://internal.private-domain.test/secret for details', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('non-placeholder URL')));
});
});
test('assertNoBundledMcpConfig catches a bearer token', () => {
const stub = path.join(PLUGIN_ROOT, 'scratch-bearer.md');
withExtraFile(stub, 'Authorization: Bearer abc123def456ghi789jkl012mno', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('bearer token')));
});
});
test('assertInterfaceFields catches invalid brandColor', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.brandColor = 'red'; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('brandColor must match')));
});
});
test('assertInterfaceFields catches too-long shortDescription', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.shortDescription = 'x'.repeat(100); }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('shortDescription must be 64')));
});
});
test('assertInterfaceFields catches unknown category', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.category = 'Photography'; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('category must be one of')));
});
});
test('assertInterfaceFields catches invalid capability', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.capabilities = ['Magic']; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('capabilities contains invalid value')));
});
});
test('assertInterfaceFields catches empty defaultPrompt', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.defaultPrompt = []; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('defaultPrompt')));
});
});
test('assertAssets catches a missing screenshot reference', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => {
j.interface.screenshots = ['./assets/screenshots/nonexistent.png'];
}, () => {
const failures = collectFailures(assets.assertAssets);
assert.ok(failures.some((f) => f.includes('screenshots entry is missing')));
});
});
test('assertAssets catches a non-PNG logo', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.logo = './assets/twenty-logo.svg'; }, () => {
const failures = collectFailures(assets.assertAssets);
assert.ok(failures.some((f) => f.includes('logo must be a PNG')));
});
});
test('assertMarketplaceTemplate catches version drift', () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (t) => {
t.plugins[0].version = '0.0.0';
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((f) => f.includes('version must match')));
});
});
test('assertSkillTriggerPhrases catches a SKILL.md missing the When To Use section', () => {
const skillPath = path.join(PLUGIN_ROOT, 'skills', 'create-app', 'SKILL.md');
withFileMutation(skillPath, (original) => original.replace(/^#+\s+When To Use[\s\S]*?(?=\n#\s)/m, ''), () => {
const failures = collectFailures(skills.assertSkillTriggerPhrases);
assert.ok(failures.some((f) => f.includes('create-app') && f.includes('When To Use')));
});
});
test('assertTestingGuidance catches missing manage-app test target instructions', () => {
const skillPath = path.join(PLUGIN_ROOT, 'skills', 'manage-app', 'SKILL.md');
withFileMutation(skillPath, (original) => original.replace('TWENTY_API_URL=http://localhost:2021 yarn test', 'yarn test'), () => {
const failures = collectFailures(crossDocContracts.assertTestingGuidance);
assert.ok(
failures.some((f) => f.includes('manage-app/SKILL.md') && f.includes('TWENTY_API_URL')),
`expected manage-app test target failure, got: ${failures.join('; ')}`,
);
});
});
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
set -euo pipefail
name=""
url="${TWENTY_MCP_URL:-}"
force_login="false"
print_url="false"
usage() {
cat <<'EOF'
Usage: setup-mcp.sh [--name server-name] [--force-login] [--print-url] <workspace-url-or-mcp-url>
Examples:
setup-mcp.sh acme.twenty.com
setup-mcp.sh --force-login https://crm.example.com
setup-mcp.sh --name twenty-local acme.localhost:3001
setup-mcp.sh --name twenty-prod https://crm.example.com/mcp
OAuth:
Codex may open OAuth automatically after the server is added.
Use --force-login only if that does not happen.
Environment:
TWENTY_MCP_URL MCP URL to use when no URL argument is provided.
EOF
}
normalize_url() {
local raw="$1"
raw="${raw#"${raw%%[![:space:]]*}"}"
raw="${raw%"${raw##*[![:space:]]}"}"
raw="${raw%/}"
if [[ "$raw" != http://* && "$raw" != https://* ]]; then
if [[ "$raw" == localhost* || "$raw" == 127.* || "$raw" == "[::1]"* || "$raw" == *.localhost || "$raw" == *.localhost:* ]]; then
raw="http://$raw"
else
raw="https://$raw"
fi
fi
if [[ "$raw" != */mcp ]]; then
raw="${raw%/}/mcp"
fi
echo "$raw"
}
derive_name() {
local normalized="$1"
local host="${normalized#http://}"
host="${host#https://}"
host="${host%%/*}"
host="${host#*@}"
host="${host#[}"
host="${host%]}"
local port=""
if [[ "$host" == *:* && "$host" != *:*:* ]]; then
port="${host##*:}"
host="${host%%:*}"
fi
local stem="$host"
if [[ "$stem" == *.twenty.com ]]; then
stem="${stem%.twenty.com}"
elif [[ "$stem" == *.* && "$stem" != localhost && "$stem" != *.localhost ]]; then
stem="${stem%.*}"
fi
if [[ -n "$port" ]]; then
stem="$stem-$port"
fi
stem="$(printf '%s' "$stem" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')"
if [[ -z "$stem" ]]; then
echo "twenty"
else
echo "twenty-$stem"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--name)
name="${2:-}"
shift 2
;;
--login)
echo "Warning: --login is deprecated and ignored. Codex starts OAuth during MCP setup when needed; use --force-login only as a manual fallback." >&2
shift
;;
--force-login)
force_login="true"
shift
;;
--print-url)
print_url="true"
shift
;;
-h | --help)
usage
exit 0
;;
--)
shift
url="${1:-$url}"
break
;;
-*)
echo "Unknown option: $1" >&2
echo >&2
usage >&2
exit 1
;;
*)
url="$1"
shift
;;
esac
done
if [[ -z "$url" ]]; then
echo "A Twenty workspace URL is required, for example: https://crm.example.com" >&2
echo
usage
exit 1
fi
url="$(normalize_url "$url")"
if [[ -z "$name" ]]; then
name="$(derive_name "$url")"
fi
if [[ "$print_url" == "true" ]]; then
echo "$url"
exit 0
fi
if ! command -v codex >/dev/null 2>&1; then
echo "The codex CLI is required but was not found in PATH." >&2
exit 1
fi
is_codex_managed_shell() {
[[ -n "${CODEX_THREAD_ID:-}" || "${CODEX_INTERNAL_ORIGINATOR_OVERRIDE:-}" == *"Codex"* ]]
}
if codex mcp get "$name" >/dev/null 2>&1; then
codex mcp remove "$name" >/dev/null
fi
codex mcp add "$name" --url "$url"
echo "Configured Twenty MCP:"
echo " name: $name"
echo " url: $url"
echo
if [[ "$force_login" == "true" ]]; then
if is_codex_managed_shell; then
echo "Skipped forced OAuth login because this helper is running inside Codex."
echo "Codex may open OAuth automatically after the MCP server is added."
echo
echo "If no OAuth window opens, run:"
echo " codex mcp login $name"
exit 0
fi
codex mcp login "$name"
else
echo "Next step:"
echo " Codex may open OAuth automatically. If it does not, run:"
echo " codex mcp login $name"
fi
@@ -0,0 +1,39 @@
#!/usr/bin/env node
const metadata = require('./validators/metadata');
const assets = require('./validators/assets');
const skills = require('./validators/skills');
const references = require('./validators/references');
const crossDocContracts = require('./validators/cross-doc-contracts');
const setupHelper = require('./validators/setup-helper');
const failures = [];
const fail = (message) => failures.push(message);
metadata.assertJsonMetadata(fail);
metadata.assertNoBundledMcpConfig(fail);
metadata.assertInterfaceFields(fail);
metadata.assertMarketplaceTemplate(fail);
assets.assertAssets(fail);
skills.assertSkills(fail);
skills.assertSkillTriggerPhrases(fail);
skills.assertNoLegacySkillReferences(fail);
references.assertReferences(fail);
references.assertHowAppsWork(fail);
crossDocContracts.assertTwentyMcpFormattingContract(fail);
crossDocContracts.assertFrontComponentGuidance(fail);
crossDocContracts.assertCliGuidanceSplit(fail);
crossDocContracts.assertTestingGuidance(fail);
setupHelper.assertSetupHelper(fail);
if (failures.length > 0) {
console.error('Twenty Codex plugin validation failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('Twenty Codex plugin validation passed.');
@@ -0,0 +1,81 @@
const fs = require('node:fs');
const path = require('node:path');
const {
MIN_LOGO_DIMENSION,
readText,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
} = require('./lib');
const assertAssets = (fail) => {
const { readJson } = createJsonReaders(fail);
const resolveInterfacePath = createInterfacePathResolver(fail);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const interfaceMetadata = pluginJson?.interface;
if (!interfaceMetadata) {
return;
}
const logoPath = resolveInterfacePath(interfaceMetadata.logo);
if (logoPath) {
if (!fs.existsSync(logoPath)) {
fail(`interface.logo file is missing: ${interfaceMetadata.logo}`);
} else if (!logoPath.toLowerCase().endsWith('.png')) {
fail(`interface.logo must be a PNG: ${interfaceMetadata.logo}`);
} else {
const dimensions = readPngDimensions(logoPath);
if (!dimensions) {
fail(`interface.logo is not a readable PNG: ${interfaceMetadata.logo}`);
} else if (dimensions.width < MIN_LOGO_DIMENSION || dimensions.height < MIN_LOGO_DIMENSION) {
fail(`interface.logo must be at least ${MIN_LOGO_DIMENSION}x${MIN_LOGO_DIMENSION} (got ${dimensions.width}x${dimensions.height})`);
}
}
}
const composerIconPath = resolveInterfacePath(interfaceMetadata.composerIcon);
if (composerIconPath) {
if (!fs.existsSync(composerIconPath)) {
fail(`interface.composerIcon file is missing: ${interfaceMetadata.composerIcon}`);
} else {
const extension = path.extname(composerIconPath).toLowerCase();
if (!['.png', '.svg'].includes(extension)) {
fail(`interface.composerIcon must be PNG or SVG: ${interfaceMetadata.composerIcon}`);
}
if (extension === '.svg') {
const contents = readText(composerIconPath);
if (!/<svg[\s>]/i.test(contents)) {
fail(`interface.composerIcon SVG must contain an <svg> root element: ${interfaceMetadata.composerIcon}`);
}
}
}
}
if (Array.isArray(interfaceMetadata.screenshots)) {
for (const screenshot of interfaceMetadata.screenshots) {
const screenshotPath = resolveInterfacePath(screenshot);
if (!screenshotPath) {
continue;
}
if (!fs.existsSync(screenshotPath)) {
fail(`interface.screenshots entry is missing: ${screenshot}`);
} else if (!screenshotPath.toLowerCase().endsWith('.png')) {
fail(`interface.screenshots entries must be PNG: ${screenshot}`);
} else if (!readPngDimensions(screenshotPath)) {
fail(`interface.screenshots entry is not a readable PNG: ${screenshot}`);
}
}
}
};
module.exports = { assertAssets };
@@ -0,0 +1,360 @@
const path = require('node:path');
const { PLUGIN_ROOT, readText } = require('./lib');
const assertTwentyMcpFormattingContract = (fail) => {
const skillPath = path.join(PLUGIN_ROOT, 'skills/use-twenty-mcp/SKILL.md');
const resultFormattingPath = path.join(PLUGIN_ROOT, 'references/use-twenty-mcp/result-formatting.md');
const skill = readText(skillPath);
const formatting = readText(resultFormattingPath);
const requiredSkillFragments = [
'# Output Contract',
'If the tool output includes `recordReferences`',
'MUST link each display name back to Twenty',
'{workspaceOrigin}/object/{objectNameSingular}/{recordId}',
'Never show unlinked record names',
];
for (const fragment of requiredSkillFragments) {
if (!skill.includes(fragment)) {
fail(`use-twenty-mcp/SKILL.md is missing formatting contract fragment: ${fragment}`);
}
}
const requiredFormattingFragments = [
'## Workspace Origin',
'derive the origin from it by removing the trailing `/mcp`',
'If `recordReferences` and workspace origin are both available',
'the first record-name column or record heading MUST link the display name',
'For recent companies with `recordReferences`, link the company name',
];
for (const fragment of requiredFormattingFragments) {
if (!formatting.includes(fragment)) {
fail(`result-formatting.md is missing record-link guidance fragment: ${fragment}`);
}
}
};
const assertFrontComponentGuidance = (fail) => {
const developSkillPath = path.join(PLUGIN_ROOT, 'skills/develop-app/SKILL.md');
const layoutPath = path.join(PLUGIN_ROOT, 'references/develop-app/layout.md');
const frontComponentsPath = path.join(PLUGIN_ROOT, 'references/develop-app/front-components.md');
const standalonePagesPath = path.join(PLUGIN_ROOT, 'references/develop-app/standalone-pages.md');
const appStructurePath = path.join(PLUGIN_ROOT, 'references/develop-app/app-structure.md');
const frontComponentUiPath = path.join(PLUGIN_ROOT, 'references/design/front-component-ui.md');
const developSkill = readText(developSkillPath);
const layout = readText(layoutPath);
const frontComponents = readText(frontComponentsPath);
const standalonePages = readText(standalonePagesPath);
const appStructure = readText(appStructurePath);
const frontComponentUi = readText(frontComponentUiPath);
const requiredDevelopSkillFragments = [
'references/develop-app/front-components.md',
'references/develop-app/standalone-pages.md',
'Twenty UI imports',
'Use `layout.md` for placement, `standalone-pages.md` for full-page custom UI, and `front-component-ui.md` for visual design and Twenty UI component selection',
];
for (const fragment of requiredDevelopSkillFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing front component guidance: ${fragment}`);
}
}
const requiredLayoutFragments = [
'## Front Component Widgets',
'frontComponentUniversalIdentifier',
'A `frontComponentId` is not the same value',
'use `front-components.md`',
];
for (const fragment of requiredLayoutFragments) {
if (!layout.includes(fragment)) {
fail(`layout.md is missing front component guidance: ${fragment}`);
}
}
const requiredFrontComponentFragments = [
'# Front Components',
'defineFrontComponent',
'Use `twenty-sdk/front-component`',
'Use `twenty-client-sdk/core` or `twenty-client-sdk/metadata`',
'Use `twenty-sdk/ui` for Twenty UI components',
'Do not import from `twenty-ui` directly',
'ThemeProvider',
'example-sources/twenty-ui-example.front-component.tsx',
'themeCssVariables',
'mocks `twenty-sdk/ui` during manifest extraction',
'A clean typecheck and sync is not runtime verification',
'without a `FrontComponent error`',
'hard refresh',
];
for (const fragment of requiredFrontComponentFragments) {
if (!frontComponents.includes(fragment)) {
fail(`front-components.md is missing runtime guidance: ${fragment}`);
}
}
const requiredStandalonePageFragments = [
'# Standalone Pages',
'custom page content should be rendered through a `FRONT_COMPONENT` widget',
'There does not appear to be a separate public "page body component" API',
"type: 'STANDALONE_PAGE'",
'NavigationMenuItemType.PAGE_LAYOUT',
'PageLayoutTabLayoutMode.CANVAS',
'gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 }',
'12 x 12 fill pattern',
'Full-Page Layout Guidance',
'black screen',
'yarn twenty dev --once',
];
for (const fragment of requiredStandalonePageFragments) {
if (!standalonePages.includes(fragment)) {
fail(`standalone-pages.md is missing standalone page guidance: ${fragment}`);
}
}
const requiredAppStructureFragments = [
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
];
for (const fragment of requiredAppStructureFragments) {
if (!appStructure.includes(fragment)) {
fail(`app-structure.md is missing validation checklist command: ${fragment}`);
}
}
const requiredUiDesignFragments = [
'# Design Rules',
'Do not use this reference for source files, registration, runtime imports, data access, CLI commands, or browser verification',
'## Twenty UI Defaults',
'Prefer Twenty UI primitives',
'Use `Callout`',
'Use `Button`',
'Use `Tag`, `Status`, `Chip`, `Label`, and `Avatar`',
'Use `themeCssVariables`',
'Design the visible states',
];
for (const fragment of requiredUiDesignFragments) {
if (!frontComponentUi.includes(fragment)) {
fail(`front-component-ui.md is missing design-only guidance: ${fragment}`);
}
}
const forbiddenUiFragments = [
'# Runtime Safety',
'ReactCurrentDispatcher',
'yarn twenty',
'without a `FrontComponent error`',
];
for (const fragment of forbiddenUiFragments) {
if (frontComponentUi.includes(fragment)) {
fail(`front-component-ui.md should stay design-only and not include: ${fragment}`);
}
}
};
const assertCliGuidanceSplit = (fail) => {
const developSkillPath = path.join(PLUGIN_ROOT, 'skills/develop-app/SKILL.md');
const manageSkillPath = path.join(PLUGIN_ROOT, 'skills/manage-app/SKILL.md');
const appStructurePath = path.join(PLUGIN_ROOT, 'references/develop-app/app-structure.md');
const cliAndSyncPath = path.join(PLUGIN_ROOT, 'references/manage-app/cli-and-sync.md');
const developSkill = readText(developSkillPath);
const manageSkill = readText(manageSkillPath);
const appStructure = readText(appStructurePath);
const cliAndSync = readText(cliAndSyncPath);
const requiredDevelopFragments = [
'references/develop-app/app-structure.md',
'yarn twenty dev:add',
'yarn twenty dev --once',
'switch to `manage-app`',
];
for (const fragment of requiredDevelopFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing CLI split guidance: ${fragment}`);
}
}
const requiredManageFragments = [
'references/manage-app/cli-and-sync.md',
'validation command semantics',
'sync modes',
];
for (const fragment of requiredManageFragments) {
if (!manageSkill.includes(fragment)) {
fail(`manage-app/SKILL.md is missing CLI reference guidance: ${fragment}`);
}
}
const requiredAppStructureFragments = [
'# App Structure',
'../manage-app/cli-and-sync.md',
'## Entity Creation',
'## Validation Checklist',
'run lint and typecheck once at the end (not after each individual edit)',
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
];
for (const fragment of requiredAppStructureFragments) {
if (!appStructure.includes(fragment)) {
fail(`app-structure.md is missing develop-app structure guidance: ${fragment}`);
}
}
const forbiddenAppStructureFragments = [
'# App Structure And CLI',
'Use watch mode only',
'Use watch mode for interactive development',
'Use one-shot mode for agents',
'yarn twenty dev --once --verbose',
'yarn twenty remote:list',
'Do not run `yarn twenty dev:typecheck`',
'run outside the sandbox',
'incompatible Node and Yarn',
];
for (const fragment of forbiddenAppStructureFragments) {
if (appStructure.includes(fragment)) {
fail(`app-structure.md should not own CLI semantics or forbid post-edit validation: ${fragment}`);
}
}
const requiredDevelopValidationFragments = [
'run lint and typecheck once at the end (not after each individual edit)',
'yarn twenty dev:typecheck',
'yarn lint',
];
for (const fragment of requiredDevelopValidationFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing post-edit validation guidance: ${fragment}`);
}
}
const forbiddenDevelopFragments = [
'Do not run `yarn twenty dev:typecheck`',
'debug the toolchain',
'run outside the sandbox',
];
for (const fragment of forbiddenDevelopFragments) {
if (developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md should not forbid post-edit validation or warn about the sandbox: ${fragment}`);
}
}
const requiredCliFragments = [
'# CLI And Sync',
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
'Always use one-shot sync to synchronize app changes with the active remote',
'Do not use bare `yarn twenty dev` (watch mode)',
'yarn twenty dev --once --verbose',
'yarn twenty remote:list',
'yarn twenty dev:build',
'yarn twenty app:publish',
'yarn twenty dev:function:logs',
];
for (const fragment of requiredCliFragments) {
if (!cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md is missing command guidance: ${fragment}`);
}
}
const forbiddenCliFragments = [
'run outside the sandbox',
'incompatible Node and Yarn',
'operations/command-execution.md',
];
for (const fragment of forbiddenCliFragments) {
if (cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md should not warn about the sandbox or reference the removed command-execution.md: ${fragment}`);
}
}
};
const assertTestingGuidance = (fail) => {
const manageSkillPath = path.join(PLUGIN_ROOT, 'skills/manage-app/SKILL.md');
const testsPath = path.join(PLUGIN_ROOT, 'references/develop-app/tests.md');
const cliAndSyncPath = path.join(PLUGIN_ROOT, 'references/manage-app/cli-and-sync.md');
const agentsPath = path.join(PLUGIN_ROOT, 'AGENTS.md');
const manageSkill = readText(manageSkillPath);
const tests = readText(testsPath);
const cliAndSync = readText(cliAndSyncPath);
const agents = readText(agentsPath);
const requiredManageFragments = [
'run tests for my Twenty app',
'references/develop-app/tests.md',
'yarn twenty docker:start --test',
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Do not run integration tests against the dev instance on `http://localhost:2020`',
];
for (const fragment of requiredManageFragments) {
if (!manageSkill.includes(fragment)) {
fail(`manage-app/SKILL.md is missing test execution guidance: ${fragment}`);
}
}
const requiredSharedFragments = [
'isolated test instance',
'port (`2021`)',
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Do not run integration tests against `http://localhost:2020`',
];
for (const fragment of requiredSharedFragments) {
if (!tests.includes(fragment)) {
fail(`tests.md is missing isolated integration-test guidance: ${fragment}`);
}
}
const requiredCliFragments = [
'Integration tests install and uninstall the app on their target server',
'yarn twenty docker:start --test',
'port `2021`',
'../develop-app/tests.md',
];
for (const fragment of requiredCliFragments) {
if (!cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md is missing integration-test target guidance: ${fragment}`);
}
}
const requiredAgentsFragments = [
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Integration tests must target the isolated test instance on port `2021`',
];
for (const fragment of requiredAgentsFragments) {
if (!agents.includes(fragment)) {
fail(`AGENTS.md is missing durable test target guidance: ${fragment}`);
}
}
};
module.exports = {
assertTwentyMcpFormattingContract,
assertFrontComponentGuidance,
assertCliGuidanceSplit,
assertTestingGuidance,
};
@@ -0,0 +1,178 @@
const fs = require('node:fs');
const path = require('node:path');
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
const REPO_ROOT = path.resolve(PLUGIN_ROOT, '..', '..');
const PUBLIC_DOCS_MCP_SERVER_NAME = 'twenty-docs';
const PUBLIC_DOCS_MCP_URL = 'https://docs.twenty.com/mcp';
const LEGACY_SKILL_NAMES = [
'app-readme-and-visuals',
'build-app-features',
'create-an-app',
'design-front-components',
'retrieve-and-present-data',
'setup-mcp',
];
const VALID_CAPABILITIES = new Set(['Interactive', 'Read', 'Write']);
const VALID_CATEGORIES = new Set(['Coding', 'Productivity', 'Communication', 'Data', 'Design', 'Marketing', 'Sales']);
const SHORT_DESCRIPTION_MAX = 64;
const MIN_LOGO_DIMENSION = 256;
const readText = (filePath) => fs.readFileSync(filePath, 'utf8');
const listFiles = (directory) => {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(absolutePath));
} else {
files.push(absolutePath);
}
}
return files;
};
const parseSkillFrontmatter = (skillPath) => {
const contents = readText(skillPath);
const match = contents.match(/^---\n([\s\S]*?)\n---\n/);
if (!match) {
return undefined;
}
const frontmatter = {};
for (const line of match[1].split('\n')) {
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
if (fieldMatch) {
frontmatter[fieldMatch[1]] = fieldMatch[2].replace(/^["']|["']$/g, '');
}
}
return frontmatter;
};
const parseQuotedYamlField = (contents, fieldName) => {
const match = contents.match(new RegExp(`^\\s+${fieldName}:\\s+"([^"]+)"\\s*$`, 'm'));
return match?.[1];
};
const isAllowedDocumentationHost = (hostname) => {
const host = hostname.toLowerCase();
return (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.startsWith('127.') ||
host === '[::1]' ||
host === 'example.com' ||
host.endsWith('.example.com') ||
host === 'example.twenty.com' ||
host === 'myworkspace.twenty.com' ||
host === 'myworkspace.customdomain.com' ||
host === 'your-twenty-server.com' ||
host === 'app.twenty.com' ||
host === 'twenty.com' ||
host === 'docs.twenty.com' ||
host === 'www.docker.com' ||
host === 'github.com' ||
host === 'www.w3.org' ||
host === 'developers.openai.com' ||
host === 'keepachangelog.com' ||
host === 'semver.org' ||
host.endsWith('.example')
);
};
const readPngDimensions = (filePath) => {
const buffer = fs.readFileSync(filePath);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) {
return undefined;
}
if (buffer.subarray(12, 16).toString('ascii') !== 'IHDR') {
return undefined;
}
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
};
const createJsonReaders = (fail) => {
const readJson = (relativePath) => {
const absolutePath = path.join(REPO_ROOT, relativePath);
try {
return JSON.parse(readText(absolutePath));
} catch (error) {
fail(`${relativePath} is not valid JSON: ${error.message}`);
return undefined;
}
};
const readOptionalJson = (relativePath) => {
const absolutePath = path.join(REPO_ROOT, relativePath);
if (!fs.existsSync(absolutePath)) {
return undefined;
}
return readJson(relativePath);
};
return { readJson, readOptionalJson };
};
const createInterfacePathResolver = (fail) => (relativePath) => {
if (typeof relativePath !== 'string' || relativePath.length === 0) {
return undefined;
}
if (!relativePath.startsWith('./')) {
fail(`interface path must start with ./ (got: ${relativePath})`);
return undefined;
}
const resolvedPath = path.resolve(PLUGIN_ROOT, relativePath.slice(2));
// Reject ../ traversal that escapes the plugin directory after normalization
if (resolvedPath !== PLUGIN_ROOT && !resolvedPath.startsWith(PLUGIN_ROOT + path.sep)) {
fail(`interface path must stay within the plugin directory (got: ${relativePath})`);
return undefined;
}
return resolvedPath;
};
module.exports = {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
LEGACY_SKILL_NAMES,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
MIN_LOGO_DIMENSION,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
isAllowedDocumentationHost,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
};
@@ -0,0 +1,256 @@
const fs = require('node:fs');
const path = require('node:path');
const {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
readText,
listFiles,
isAllowedDocumentationHost,
createJsonReaders,
} = require('./lib');
const assertJsonMetadata = (fail) => {
const { readJson, readOptionalJson } = createJsonReaders(fail);
const packageJson = readJson('packages/twenty-codex-plugin/package.json');
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const mcpJson = readJson('packages/twenty-codex-plugin/.mcp.json');
const marketplaceJson = readOptionalJson('.agents/plugins/marketplace.json');
if (packageJson?.version !== pluginJson?.version) {
fail('package.json version must match .codex-plugin/plugin.json version');
}
if (!packageJson?.files?.includes('.mcp.json')) {
fail('package.json files must include .mcp.json for the public docs MCP server');
}
if (!packageJson?.files?.includes('references')) {
fail('package.json files must include references for shared plugin guidance');
}
if (pluginJson?.mcpServers !== './.mcp.json') {
fail('.codex-plugin/plugin.json must declare mcpServers as ./.mcp.json');
}
const servers = mcpJson?.mcpServers;
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) {
fail('.mcp.json must declare an mcpServers object');
} else {
const serverNames = Object.keys(servers);
if (serverNames.length !== 1 || serverNames[0] !== PUBLIC_DOCS_MCP_SERVER_NAME) {
fail(`.mcp.json must only declare ${PUBLIC_DOCS_MCP_SERVER_NAME}`);
}
const docsServer = servers[PUBLIC_DOCS_MCP_SERVER_NAME];
if (!docsServer || typeof docsServer !== 'object' || Array.isArray(docsServer)) {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} must be an object`);
} else {
const docsServerKeys = Object.keys(docsServer);
if (docsServerKeys.length !== 1 || docsServerKeys[0] !== 'url') {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} must only declare a url`);
}
if (docsServer.url !== PUBLIC_DOCS_MCP_URL) {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} url must be ${PUBLIC_DOCS_MCP_URL}`);
}
}
}
const marketplaceEntry = marketplaceJson?.plugins?.find((entry) => entry.name === 'twenty');
if (marketplaceJson && !marketplaceEntry) {
fail('.agents/plugins/marketplace.json includes plugins but not the twenty plugin entry');
} else if (marketplaceEntry && marketplaceEntry.source?.path !== './packages/twenty-codex-plugin') {
fail('marketplace twenty source path must be ./packages/twenty-codex-plugin');
}
if (fs.existsSync(path.join(REPO_ROOT, 'plugins', 'twenty'))) {
fail('legacy plugins/twenty path must not exist; use packages/twenty-codex-plugin directly');
}
};
const assertNoBundledMcpConfig = (fail) => {
const gitignorePath = path.join(PLUGIN_ROOT, '.gitignore');
if (fs.existsSync(gitignorePath) && readText(gitignorePath).split(/\r?\n/).includes('.mcp.json')) {
fail('packages/twenty-codex-plugin/.gitignore must not ignore the public .mcp.json');
}
for (const filePath of listFiles(PLUGIN_ROOT)) {
const relativePath = path.relative(PLUGIN_ROOT, filePath);
if (relativePath.split(path.sep).includes('__tests__')) {
continue;
}
if (path.basename(filePath) === '.mcp.json' && relativePath !== '.mcp.json') {
fail(`workspace-specific MCP config must not be shipped: ${relativePath}`);
}
if (path.basename(filePath) === '.app.json') {
fail(`app declarations must not be shipped unless intentionally allowed in validation: ${relativePath}`);
}
const contents = readText(filePath);
const urls = contents.matchAll(/https?:\/\/[^\s"`'<>)]*/g);
for (const [rawUrl] of urls) {
let parsedUrl;
if (/[${}*]/.test(rawUrl)) {
continue;
}
try {
parsedUrl = new URL(rawUrl);
} catch {
continue;
}
if (!isAllowedDocumentationHost(parsedUrl.hostname)) {
fail(`non-placeholder URL found in ${relativePath}: ${parsedUrl.origin}`);
}
}
if (/Bearer\s+(?!YOUR_API_KEY\b)[A-Za-z0-9._-]{20,}/.test(contents)) {
fail(`possible bearer token found in ${relativePath}`);
}
if (/sk-[A-Za-z0-9_-]{20,}/.test(contents)) {
fail(`possible API key found in ${relativePath}`);
}
}
};
const assertInterfaceFields = (fail) => {
const { readJson } = createJsonReaders(fail);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const interfaceMetadata = pluginJson?.interface;
if (!interfaceMetadata || typeof interfaceMetadata !== 'object' || Array.isArray(interfaceMetadata)) {
fail('.codex-plugin/plugin.json must declare an interface object');
return;
}
const requiredStringFields = [
'displayName',
'shortDescription',
'longDescription',
'developerName',
'category',
'websiteURL',
'privacyPolicyURL',
'termsOfServiceURL',
'brandColor',
'logo',
'composerIcon',
];
for (const field of requiredStringFields) {
const value = interfaceMetadata[field];
if (typeof value !== 'string' || value.trim().length === 0) {
fail(`.codex-plugin/plugin.json interface.${field} must be a non-empty string`);
}
}
if (typeof interfaceMetadata.shortDescription === 'string' && interfaceMetadata.shortDescription.length > SHORT_DESCRIPTION_MAX) {
fail(`.codex-plugin/plugin.json interface.shortDescription must be ${SHORT_DESCRIPTION_MAX} characters or fewer`);
}
if (typeof interfaceMetadata.brandColor === 'string' && !/^#[0-9a-fA-F]{6}$/.test(interfaceMetadata.brandColor)) {
fail('.codex-plugin/plugin.json interface.brandColor must match #RRGGBB hex format');
}
if (typeof interfaceMetadata.category === 'string' && !VALID_CATEGORIES.has(interfaceMetadata.category)) {
fail(`.codex-plugin/plugin.json interface.category must be one of: ${[...VALID_CATEGORIES].join(', ')}`);
}
if (!Array.isArray(interfaceMetadata.capabilities) || interfaceMetadata.capabilities.length === 0) {
fail('.codex-plugin/plugin.json interface.capabilities must be a non-empty array');
} else {
for (const capability of interfaceMetadata.capabilities) {
if (!VALID_CAPABILITIES.has(capability)) {
fail(`.codex-plugin/plugin.json interface.capabilities contains invalid value: ${capability}`);
}
}
}
if (!Array.isArray(interfaceMetadata.defaultPrompt) || interfaceMetadata.defaultPrompt.length === 0) {
fail('.codex-plugin/plugin.json interface.defaultPrompt must be a non-empty array of strings');
} else {
for (const prompt of interfaceMetadata.defaultPrompt) {
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
fail('.codex-plugin/plugin.json interface.defaultPrompt entries must be non-empty strings');
}
}
}
if (!Array.isArray(interfaceMetadata.screenshots)) {
fail('.codex-plugin/plugin.json interface.screenshots must be an array (use [] if no screenshots yet)');
}
};
const assertMarketplaceTemplate = (fail) => {
const { readJson, readOptionalJson } = createJsonReaders(fail);
const templatePath = 'packages/twenty-codex-plugin/templates/marketplace.example.json';
const template = readOptionalJson(templatePath);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
if (!template) {
fail(`marketplace template is missing at ${templatePath}`);
return;
}
const entries = template.plugins;
if (!Array.isArray(entries) || entries.length === 0) {
fail(`${templatePath} must declare a non-empty plugins array`);
return;
}
const twentyEntry = entries.find((entry) => entry?.name === 'twenty');
if (!twentyEntry) {
fail(`${templatePath} must include a plugin entry named "twenty"`);
return;
}
if (twentyEntry.version !== pluginJson?.version) {
fail(`${templatePath} twenty.version must match plugin.json version`);
}
if (twentyEntry.source?.path !== './packages/twenty-codex-plugin') {
fail(`${templatePath} twenty.source.path must be ./packages/twenty-codex-plugin`);
}
if (!twentyEntry.policy?.installation) {
fail(`${templatePath} twenty.policy.installation is required`);
}
if (!twentyEntry.policy?.authentication) {
fail(`${templatePath} twenty.policy.authentication is required`);
}
if (twentyEntry.category !== pluginJson?.interface?.category) {
fail(`${templatePath} twenty.category must match plugin.json interface.category`);
}
};
module.exports = {
assertJsonMetadata,
assertNoBundledMcpConfig,
assertInterfaceFields,
assertMarketplaceTemplate,
};
@@ -0,0 +1,99 @@
const fs = require('node:fs');
const path = require('node:path');
const { PLUGIN_ROOT, readText } = require('./lib');
const REQUIRED_REFERENCES = [
'references/design/front-component-ui.md',
'references/develop-app/app-structure.md',
'references/develop-app/data-model.md',
'references/develop-app/front-components.md',
'references/develop-app/logic.md',
'references/develop-app/layout.md',
'references/develop-app/standalone-pages.md',
'references/develop-app/tests.md',
'references/develop-app/workflows.md',
'references/manage-app/cli-and-sync.md',
'references/publish-app/prepare-for-app-store.md',
'references/concepts/how-apps-work.md',
'references/use-twenty-mcp/setup.md',
'references/use-twenty-mcp/result-formatting.md',
];
const assertReferences = (fail) => {
for (const relativePath of REQUIRED_REFERENCES) {
const absolutePath = path.join(PLUGIN_ROOT, relativePath);
if (!fs.existsSync(absolutePath)) {
fail(`required reference is missing: ${relativePath}`);
}
}
};
const assertHowAppsWork = (fail) => {
const howAppsWorkPath = path.join(PLUGIN_ROOT, 'references/concepts/how-apps-work.md');
if (!fs.existsSync(howAppsWorkPath)) {
fail('required reference is missing: references/concepts/how-apps-work.md');
return;
}
const howAppsWork = readText(howAppsWorkPath);
const requiredFragments = [
'# How Twenty Apps Work',
'## What Is A Twenty App',
'standalone npm package',
'## SDK Packages',
'twenty-sdk',
'twenty-client-sdk',
'## Twenty Instances And Remotes',
'## Local Development Environment',
'## App Lifecycle',
'create-twenty-app',
'## Front Component Rendering',
'Remote DOM',
'## App File Structure',
'application-config.ts',
'## Sharing An App',
'## Key Concepts',
'Universal identifiers',
];
for (const fragment of requiredFragments) {
if (!howAppsWork.includes(fragment)) {
fail(`how-apps-work.md is missing foundational guidance: ${fragment}`);
}
}
// use-twenty-mcp is intentionally excluded: it covers consuming the Twenty MCP
// server to retrieve and present workspace records, not building apps, so the
// app-foundations doc (how-apps-work.md) is not a relevant prerequisite for it.
const skillsToCheck = [
'skills/create-app/SKILL.md',
'skills/develop-app/SKILL.md',
'skills/manage-app/SKILL.md',
'skills/publish-app/SKILL.md',
];
for (const skillRelPath of skillsToCheck) {
const skillPath = path.join(PLUGIN_ROOT, skillRelPath);
if (!fs.existsSync(skillPath)) {
fail(`required skill is missing: ${skillRelPath}`);
continue;
}
const skill = readText(skillPath);
if (!skill.includes('references/concepts/how-apps-work.md')) {
fail(`${skillRelPath} must reference how-apps-work.md`);
}
}
};
module.exports = {
REQUIRED_REFERENCES,
assertReferences,
assertHowAppsWork,
};
@@ -0,0 +1,48 @@
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { PLUGIN_ROOT } = require('./lib');
const URL_NORMALIZATION_CASES = [
['myworkspace.localhost:3001', 'http://myworkspace.localhost:3001/mcp'],
['crm.example.com', 'https://crm.example.com/mcp'],
['https://crm.example.com/mcp', 'https://crm.example.com/mcp'],
];
const assertSetupHelper = (fail) => {
const setupScript = path.join(PLUGIN_ROOT, 'scripts', 'setup-mcp.sh');
const syntaxCheck = spawnSync('bash', ['-n', setupScript], { encoding: 'utf8' });
// spawnSync sets `error` (and leaves status/stdout/stderr null) when bash itself
// cannot be launched — surface that instead of blaming the script's syntax.
if (syntaxCheck.error) {
fail(`could not run bash to validate setup-mcp.sh: ${syntaxCheck.error.message}`);
return;
}
if (syntaxCheck.status !== 0) {
fail(`setup-mcp.sh has invalid bash syntax: ${syntaxCheck.stderr.trim()}`);
}
for (const [input, expected] of URL_NORMALIZATION_CASES) {
const result = spawnSync('bash', [setupScript, '--print-url', input], { encoding: 'utf8' });
if (result.error) {
fail(`could not run bash for setup-mcp.sh --print-url ${input}: ${result.error.message}`);
continue;
}
if (result.status !== 0) {
fail(`setup-mcp.sh --print-url ${input} failed: ${result.stderr.trim()}`);
continue;
}
const actual = result.stdout.trim();
if (actual !== expected) {
fail(`setup-mcp.sh normalized ${input} to ${actual}, expected ${expected}`);
}
}
};
module.exports = { assertSetupHelper };
@@ -0,0 +1,155 @@
const fs = require('node:fs');
const path = require('node:path');
const {
PLUGIN_ROOT,
LEGACY_SKILL_NAMES,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
} = require('./lib');
const EXPECTED_CANONICAL_SKILLS = [
'create-app',
'develop-app',
'manage-app',
'publish-app',
'use-twenty-mcp',
];
const assertSkills = (fail) => {
const skillsRoot = path.join(PLUGIN_ROOT, 'skills');
const skillDirectories = fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const skillName of EXPECTED_CANONICAL_SKILLS) {
if (!skillDirectories.includes(skillName)) {
fail(`canonical skill is missing: ${skillName}`);
}
}
for (const skillName of LEGACY_SKILL_NAMES) {
if (skillDirectories.includes(skillName)) {
fail(`legacy skill directory must be transferred out of skills/: ${skillName}`);
}
}
for (const skillName of skillDirectories) {
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
const agentsPath = path.join(skillsRoot, skillName, 'agents', 'openai.yaml');
if (!fs.existsSync(skillPath)) {
fail(`${skillName} is missing SKILL.md`);
continue;
}
const frontmatter = parseSkillFrontmatter(skillPath);
if (!frontmatter) {
fail(`${skillName}/SKILL.md is missing YAML frontmatter`);
} else {
const frontmatterKeys = Object.keys(frontmatter).sort();
if (frontmatter.name !== skillName) {
fail(`${skillName}/SKILL.md frontmatter name must match its directory`);
}
if (!frontmatter.description) {
fail(`${skillName}/SKILL.md frontmatter description is required`);
}
if (frontmatterKeys.some((key) => !['description', 'name'].includes(key))) {
fail(`${skillName}/SKILL.md frontmatter should only include name and description`);
}
}
if (!fs.existsSync(agentsPath)) {
fail(`${skillName} is missing agents/openai.yaml`);
continue;
}
const agentsYaml = readText(agentsPath);
const displayName = parseQuotedYamlField(agentsYaml, 'display_name');
const shortDescription = parseQuotedYamlField(agentsYaml, 'short_description');
const defaultPrompt = parseQuotedYamlField(agentsYaml, 'default_prompt');
if (!displayName) {
fail(`${skillName}/agents/openai.yaml is missing interface.display_name`);
}
if (!shortDescription) {
fail(`${skillName}/agents/openai.yaml is missing interface.short_description`);
} else if (shortDescription.length > 64) {
fail(`${skillName}/agents/openai.yaml short_description must be 64 characters or fewer`);
}
if (!defaultPrompt) {
fail(`${skillName}/agents/openai.yaml is missing interface.default_prompt`);
} else if (!defaultPrompt.includes(`$${skillName}`)) {
fail(`${skillName}/agents/openai.yaml default_prompt must mention $${skillName}`);
}
}
};
const assertSkillTriggerPhrases = (fail) => {
const skillsRoot = path.join(PLUGIN_ROOT, 'skills');
const skillDirectories = fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
for (const skillName of skillDirectories) {
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
if (!fs.existsSync(skillPath)) {
continue;
}
const contents = readText(skillPath);
if (!/^#+\s+When To Use\s*$/m.test(contents)) {
fail(`${skillName}/SKILL.md must include a "When To Use" section with representative trigger phrases`);
}
}
};
const assertNoLegacySkillReferences = (fail) => {
const filesToCheck = listFiles(PLUGIN_ROOT).filter((filePath) => {
const extension = path.extname(filePath);
return ['.md', '.yaml', '.yml'].includes(extension);
});
for (const filePath of filesToCheck) {
const relativePath = path.relative(PLUGIN_ROOT, filePath);
const contents = readText(filePath);
for (const legacySkillName of LEGACY_SKILL_NAMES) {
if (contents.includes(`name: ${legacySkillName}`)) {
fail(`${relativePath} must not declare legacy skill name ${legacySkillName}`);
}
const mentionPattern =
legacySkillName === 'setup-mcp'
? /(^|[^A-Za-z0-9_-])setup-mcp(?!\.sh)(?=$|[^A-Za-z0-9_-])/
: new RegExp(
`(^|[^A-Za-z0-9_-])${legacySkillName}(?=$|[^A-Za-z0-9_-])`,
);
if (mentionPattern.test(contents)) {
fail(`${relativePath} must not mention legacy skill name ${legacySkillName}`);
}
}
}
};
module.exports = {
EXPECTED_CANONICAL_SKILLS,
assertSkills,
assertSkillTriggerPhrases,
assertNoLegacySkillReferences,
};