Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
366 lines
10 KiB
JavaScript
366 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
DISTRIBUTION_ROOT,
|
|
CANONICAL_SKILL_NAMES,
|
|
readText,
|
|
listFiles,
|
|
} from './lib.js';
|
|
import {
|
|
isAllowedDocumentationHost,
|
|
parseSkillFrontmatter,
|
|
} from './validators/lib.js';
|
|
|
|
// The portable skills must work after `npx skills add` copies a single skill
|
|
// directory into an agent's skill folder, outside the monorepo. Nothing in a
|
|
// skill may depend on the Codex wrapper, the monorepo layout, or a sibling
|
|
// skill directory.
|
|
const FORBIDDEN_PORTABILITY_FRAGMENTS = [
|
|
'packages/twenty-codex-plugin',
|
|
'packages/twenty-agent-skills',
|
|
'.codex-plugin',
|
|
'openai.yaml',
|
|
'AGENTS.md',
|
|
'marketplace.json',
|
|
];
|
|
|
|
const PATH_LIKE_REFERENCE_PATTERN =
|
|
/(?:(?:\.\.\/)+|references\/)[A-Za-z0-9._/-]+\.md/g;
|
|
|
|
const assertSkillDirectories = (skillsRoot, fail) => {
|
|
if (!fs.existsSync(skillsRoot)) {
|
|
fail('skills/ does not exist; run the build first');
|
|
return [];
|
|
}
|
|
|
|
const skillDirectories = fs
|
|
.readdirSync(skillsRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
for (const skillName of CANONICAL_SKILL_NAMES) {
|
|
if (!skillDirectories.includes(skillName)) {
|
|
fail(`portable skill is missing: ${skillName}`);
|
|
}
|
|
}
|
|
|
|
for (const skillName of skillDirectories) {
|
|
if (!CANONICAL_SKILL_NAMES.includes(skillName)) {
|
|
fail(`unexpected skill directory: ${skillName}`);
|
|
}
|
|
}
|
|
|
|
return skillDirectories;
|
|
};
|
|
|
|
const assertSkillFrontmatter = ({ skillsRoot, skillName, fail }) => {
|
|
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
|
|
|
|
if (!fs.existsSync(skillPath)) {
|
|
fail(`${skillName} is missing SKILL.md`);
|
|
return;
|
|
}
|
|
|
|
const frontmatter = parseSkillFrontmatter(skillPath);
|
|
|
|
if (!frontmatter) {
|
|
fail(`${skillName}/SKILL.md is missing YAML frontmatter`);
|
|
return;
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
const frontmatterKeys = Object.keys(frontmatter);
|
|
|
|
if (frontmatterKeys.some((key) => !['description', 'name'].includes(key))) {
|
|
fail(
|
|
`${skillName}/SKILL.md frontmatter should only include name and description`,
|
|
);
|
|
}
|
|
|
|
if (!/^#+\s+When To Use\s*$/m.test(readText(skillPath))) {
|
|
fail(`${skillName}/SKILL.md must include a "When To Use" section`);
|
|
}
|
|
};
|
|
|
|
const assertSelfContainedReferences = ({ skillsRoot, skillName, fail }) => {
|
|
const skillRoot = path.join(skillsRoot, skillName);
|
|
|
|
for (const filePath of listFiles(skillRoot)) {
|
|
const relativePath = path.relative(skillsRoot, filePath);
|
|
|
|
// Optional Codex display metadata is carried by the same portable directory.
|
|
if (
|
|
path.relative(skillRoot, filePath) === path.join('agents', 'openai.yaml')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (path.extname(filePath) !== '.md') {
|
|
fail(
|
|
`portable skills may only contain markdown and agents/openai.yaml: ${relativePath}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const contents = readText(filePath);
|
|
|
|
for (const [reference] of contents.matchAll(PATH_LIKE_REFERENCE_PATTERN)) {
|
|
const baseDirectory = reference.startsWith('references/')
|
|
? skillRoot
|
|
: path.dirname(filePath);
|
|
const resolvedPath = path.resolve(baseDirectory, reference);
|
|
|
|
if (!resolvedPath.startsWith(skillRoot + path.sep)) {
|
|
fail(
|
|
`${relativePath} references a path outside its skill directory: ${reference}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
fail(`${relativePath} references a missing file: ${reference}`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const assertPortability = ({ skillsRoot, skillName, fail }) => {
|
|
const skillRoot = path.join(skillsRoot, skillName);
|
|
|
|
for (const filePath of listFiles(skillRoot)) {
|
|
const relativePath = path.relative(skillsRoot, filePath);
|
|
const contents = readText(filePath);
|
|
|
|
for (const fragment of FORBIDDEN_PORTABILITY_FRAGMENTS) {
|
|
if (contents.includes(fragment)) {
|
|
fail(
|
|
`${relativePath} must not depend on non-portable content: ${fragment}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [rawUrl] of contents.matchAll(/https?:\/\/[^\s"`'<>)]*/g)) {
|
|
if (/[${}*]/.test(rawUrl)) {
|
|
continue;
|
|
}
|
|
|
|
let parsedUrl;
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Installing two skills side by side must never produce two conflicting
|
|
// versions of a shared reference doc.
|
|
const assertSharedReferenceConsistency = (
|
|
skillsRoot,
|
|
skillDirectories,
|
|
fail,
|
|
) => {
|
|
const referenceContentsByRelativePath = new Map();
|
|
|
|
for (const skillName of skillDirectories) {
|
|
const referencesRoot = path.join(skillsRoot, skillName, 'references');
|
|
|
|
if (!fs.existsSync(referencesRoot)) {
|
|
continue;
|
|
}
|
|
|
|
for (const filePath of listFiles(referencesRoot)) {
|
|
const relativeReference = path.relative(referencesRoot, filePath);
|
|
const contents = readText(filePath);
|
|
const existing = referenceContentsByRelativePath.get(relativeReference);
|
|
|
|
if (existing && existing.contents !== contents) {
|
|
fail(
|
|
`shared reference diverges between ${existing.skillName} and ${skillName}: references/${relativeReference}`,
|
|
);
|
|
} else if (!existing) {
|
|
referenceContentsByRelativePath.set(relativeReference, {
|
|
skillName,
|
|
contents,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const APP_SKILLS = ['create-app', 'develop-app', 'manage-app', 'publish-app'];
|
|
const OPERATING_RULES_REFERENCE = path.join(
|
|
'references',
|
|
'concepts',
|
|
'operating-rules.md',
|
|
);
|
|
|
|
// The operating rules are the one piece of guidance shared by every app skill.
|
|
// `skills add` copies a single skill directory, so each one must carry its own
|
|
// copy and link to it; assertSharedReferenceConsistency keeps the copies equal.
|
|
const assertOperatingRulesShipped = (skillsRoot, fail) => {
|
|
for (const skillName of APP_SKILLS) {
|
|
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
|
|
const operatingRulesPath = path.join(
|
|
skillsRoot,
|
|
skillName,
|
|
OPERATING_RULES_REFERENCE,
|
|
);
|
|
|
|
if (!fs.existsSync(operatingRulesPath)) {
|
|
fail(`${skillName} does not ship ${OPERATING_RULES_REFERENCE}`);
|
|
}
|
|
|
|
if (
|
|
fs.existsSync(skillPath) &&
|
|
!readText(skillPath).includes('references/concepts/operating-rules.md')
|
|
) {
|
|
fail(
|
|
`${skillName}/SKILL.md must link to references/concepts/operating-rules.md`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Reference docs get mentioned in prose by bare filename. Those mentions escape
|
|
// PATH_LIKE_REFERENCE_PATTERN, so a skill can ship a doc that points at another
|
|
// doc it does not carry. Generic names such as README.md refer to the user's own
|
|
// files, so only basenames that exist somewhere in the collection are checked.
|
|
const assertNoDanglingDocMentions = (skillsRoot, skillDirectories, fail) => {
|
|
const collectionDocNames = new Set();
|
|
|
|
for (const skillName of skillDirectories) {
|
|
for (const filePath of listFiles(path.join(skillsRoot, skillName))) {
|
|
collectionDocNames.add(path.basename(filePath));
|
|
}
|
|
}
|
|
|
|
for (const skillName of skillDirectories) {
|
|
const skillRoot = path.join(skillsRoot, skillName);
|
|
const bundledDocNames = new Set(
|
|
listFiles(skillRoot).map((filePath) => path.basename(filePath)),
|
|
);
|
|
|
|
for (const filePath of listFiles(skillRoot)) {
|
|
const relativePath = path.relative(skillsRoot, filePath);
|
|
|
|
for (const [, docName] of readText(filePath).matchAll(
|
|
/`([A-Za-z0-9_-]+\.md)`/g,
|
|
)) {
|
|
if (collectionDocNames.has(docName) && !bundledDocNames.has(docName)) {
|
|
fail(
|
|
`${relativePath} mentions ${docName}, which ${skillName} does not ship`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const assertSelfHostedSupport = (skillsRoot, fail) => {
|
|
const createAppPath = path.join(skillsRoot, 'create-app', 'SKILL.md');
|
|
const setupPath = path.join(
|
|
skillsRoot,
|
|
'use-twenty-mcp',
|
|
'references',
|
|
'use-twenty-mcp',
|
|
'setup.md',
|
|
);
|
|
|
|
if (
|
|
fs.existsSync(createAppPath) &&
|
|
!readText(createAppPath).includes('self-hosted')
|
|
) {
|
|
fail(
|
|
'create-app/SKILL.md must present self-hosted instances as a supported target',
|
|
);
|
|
}
|
|
|
|
if (
|
|
fs.existsSync(setupPath) &&
|
|
!readText(setupPath).includes('Never require a `twenty.com` workspace')
|
|
) {
|
|
fail('use-twenty-mcp setup.md must not require a twenty.com workspace');
|
|
}
|
|
};
|
|
|
|
const validatePortableSkills = (skillsRoot, fail) => {
|
|
const skillDirectories = assertSkillDirectories(skillsRoot, fail);
|
|
|
|
for (const skillName of skillDirectories) {
|
|
assertSkillFrontmatter({ skillsRoot, skillName, fail });
|
|
assertSelfContainedReferences({ skillsRoot, skillName, fail });
|
|
assertPortability({ skillsRoot, skillName, fail });
|
|
}
|
|
|
|
assertSharedReferenceConsistency(skillsRoot, skillDirectories, fail);
|
|
assertOperatingRulesShipped(skillsRoot, fail);
|
|
assertNoDanglingDocMentions(skillsRoot, skillDirectories, fail);
|
|
assertSelfHostedSupport(skillsRoot, fail);
|
|
};
|
|
|
|
const isEntryPoint =
|
|
process.argv[1] !== undefined &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (isEntryPoint) {
|
|
const failures = [];
|
|
|
|
validatePortableSkills(path.join(DISTRIBUTION_ROOT, 'skills'), (message) =>
|
|
failures.push(message),
|
|
);
|
|
|
|
if (failures.length > 0) {
|
|
console.error('Twenty agent skills validation failed:');
|
|
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('Twenty agent skills validation passed.');
|
|
}
|
|
|
|
export {
|
|
FORBIDDEN_PORTABILITY_FRAGMENTS,
|
|
assertSkillDirectories,
|
|
assertSkillFrontmatter,
|
|
assertSelfContainedReferences,
|
|
assertPortability,
|
|
assertSharedReferenceConsistency,
|
|
assertOperatingRulesShipped,
|
|
assertNoDanglingDocMentions,
|
|
assertSelfHostedSupport,
|
|
validatePortableSkills,
|
|
};
|