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:
co-authored by
martmull
bosiraphael
parent
cb744b2eeb
commit
1642be86f5
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user