snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,372 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { CANONICAL_SKILL_NAMES } from '../lib.js';
import {
assertSkillFrontmatter,
assertSelfContainedReferences,
assertPortability,
assertSharedReferenceConsistency,
assertOperatingRulesShipped,
assertNoDanglingDocMentions,
validatePortableSkills,
} from '../validate-distribution.js';
import { writeFixtureFile } from './fixtures.js';
const writeValidSkill = (skillsRoot, skillName) => {
writeFixtureFile(
skillsRoot,
`${skillName}/SKILL.md`,
`---\nname: ${skillName}\ndescription: Demo skill.\n---\n\n# When To Use\n\nRead \`references/concepts/basics.md\` and \`references/concepts/operating-rules.md\`.\n`,
);
writeFixtureFile(
skillsRoot,
`${skillName}/references/concepts/basics.md`,
'Basics.\n',
);
writeFixtureFile(
skillsRoot,
`${skillName}/references/concepts/operating-rules.md`,
'Rules.\n',
);
};
describe('validatePortableSkills', () => {
let skillsRoot;
let failures;
const fail = (message) => failures.push(message);
beforeEach(() => {
skillsRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-skills-validate-'),
);
failures = [];
});
afterEach(() => {
fs.rmSync(skillsRoot, { recursive: true, force: true });
});
it('should pass for a complete, self-contained skill set', () => {
for (const skillName of CANONICAL_SKILL_NAMES) {
writeValidSkill(skillsRoot, skillName);
}
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo skill.\n---\n\n# When To Use\n\nWorks against self-hosted instances. Read `references/concepts/operating-rules.md`.\n',
);
validatePortableSkills(skillsRoot, fail);
assert.deepEqual(failures, []);
});
it('should fail when a portable skill is missing or unexpected', () => {
writeValidSkill(skillsRoot, 'create-app');
writeValidSkill(skillsRoot, 'not-a-twenty-skill');
validatePortableSkills(skillsRoot, fail);
assert.ok(failures.includes('portable skill is missing: develop-app'));
assert.ok(
failures.includes('unexpected skill directory: not-a-twenty-skill'),
);
});
it('should fail when frontmatter name does not match the directory', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: other-name\ndescription: Demo.\n---\n\n# When To Use\n\nText.\n',
);
assertSkillFrontmatter({ skillsRoot, skillName: 'create-app', fail });
assert.ok(
failures.includes(
'create-app/SKILL.md frontmatter name must match its directory',
),
);
});
it('should fail when frontmatter includes extra fields or misses the trigger section', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo.\nlicense: MIT\n---\n\nNo trigger section.\n',
);
assertSkillFrontmatter({ skillsRoot, skillName: 'create-app', fail });
assert.ok(
failures.includes(
'create-app/SKILL.md frontmatter should only include name and description',
),
);
assert.ok(
failures.includes(
'create-app/SKILL.md must include a "When To Use" section',
),
);
});
it('should fail when a reference escapes the skill directory', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo.\n---\n\n# When To Use\n\nRead `../../references/concepts/basics.md`.\n',
);
assertSelfContainedReferences({
skillsRoot,
skillName: 'create-app',
fail,
});
assert.ok(
failures.some((failure) =>
failure.includes('references a path outside its skill directory'),
),
);
});
it('should fail when a referenced file is missing after installation', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo.\n---\n\n# When To Use\n\nRead `references/concepts/missing.md`.\n',
);
assertSelfContainedReferences({
skillsRoot,
skillName: 'create-app',
fail,
});
assert.ok(
failures.includes(
'create-app/SKILL.md references a missing file: references/concepts/missing.md',
),
);
});
it('should fail when a skill ships an unexpected executable file', () => {
writeValidSkill(skillsRoot, 'create-app');
writeFixtureFile(skillsRoot, 'create-app/script.js', 'console.log(1);\n');
assertSelfContainedReferences({
skillsRoot,
skillName: 'create-app',
fail,
});
assert.ok(
failures.includes(
'portable skills may only contain markdown and agents/openai.yaml: create-app/script.js',
),
);
});
it('should fail when content depends on the codex plugin wrapper', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo.\n---\n\n# When To Use\n\nRun `bash packages/twenty-codex-plugin/scripts/setup-mcp.sh`.\n',
);
assertPortability({ skillsRoot, skillName: 'create-app', fail });
assert.ok(
failures.includes(
'create-app/SKILL.md must not depend on non-portable content: packages/twenty-codex-plugin',
),
);
});
it('should fail on non-placeholder URLs', () => {
writeFixtureFile(
skillsRoot,
'create-app/SKILL.md',
'---\nname: create-app\ndescription: Demo.\n---\n\n# When To Use\n\nVisit https://some-random-host.dev/docs.\n',
);
assertPortability({ skillsRoot, skillName: 'create-app', fail });
assert.ok(
failures.includes(
'non-placeholder URL found in create-app/SKILL.md: https://some-random-host.dev',
),
);
});
it('should fail when a shared reference diverges between skills', () => {
writeValidSkill(skillsRoot, 'create-app');
writeValidSkill(skillsRoot, 'develop-app');
writeFixtureFile(
skillsRoot,
'develop-app/references/concepts/basics.md',
'Diverged basics.\n',
);
assertSharedReferenceConsistency(
skillsRoot,
['create-app', 'develop-app'],
fail,
);
assert.deepEqual(failures, [
'shared reference diverges between create-app and develop-app: references/concepts/basics.md',
]);
});
});
describe('assertOperatingRulesShipped', () => {
let skillsRoot;
let failures;
const fail = (message) => failures.push(message);
beforeEach(() => {
skillsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-skills-rules-'));
failures = [];
});
afterEach(() => {
fs.rmSync(skillsRoot, { recursive: true, force: true });
});
it('should fail when an app skill does not ship the operating rules', () => {
for (const skillName of CANONICAL_SKILL_NAMES) {
writeValidSkill(skillsRoot, skillName);
}
fs.rmSync(
path.join(
skillsRoot,
'manage-app/references/concepts/operating-rules.md',
),
);
assertOperatingRulesShipped(skillsRoot, fail);
assert.ok(
failures.some((failure) =>
failure.startsWith('manage-app does not ship'),
),
`expected a missing operating-rules failure, got: ${failures.join('; ')}`,
);
});
it('should fail when an app skill does not link to the operating rules', () => {
for (const skillName of CANONICAL_SKILL_NAMES) {
writeValidSkill(skillsRoot, skillName);
}
writeFixtureFile(
skillsRoot,
'publish-app/SKILL.md',
'---\nname: publish-app\ndescription: Demo skill.\n---\n\n# When To Use\n\nNo rules link here.\n',
);
assertOperatingRulesShipped(skillsRoot, fail);
assert.ok(
failures.some((failure) =>
failure.includes('publish-app/SKILL.md must link'),
),
`expected a missing link failure, got: ${failures.join('; ')}`,
);
});
});
describe('assertNoDanglingDocMentions', () => {
let skillsRoot;
let failures;
const fail = (message) => failures.push(message);
beforeEach(() => {
skillsRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-skills-mentions-'),
);
failures = [];
});
afterEach(() => {
fs.rmSync(skillsRoot, { recursive: true, force: true });
});
it('should fail when a skill mentions a doc another skill ships but it does not', () => {
writeValidSkill(skillsRoot, 'create-app');
writeValidSkill(skillsRoot, 'develop-app');
writeFixtureFile(
skillsRoot,
'develop-app/references/develop-app/front-components.md',
'Front components.\n',
);
writeFixtureFile(
skillsRoot,
'create-app/references/design/front-component-ui.md',
'Use `front-components.md` for exact imports.\n',
);
assertNoDanglingDocMentions(
skillsRoot,
['create-app', 'develop-app'],
fail,
);
assert.ok(
failures.some(
(failure) =>
failure.includes('front-components.md') &&
failure.includes('create-app does not ship'),
),
`expected a dangling mention failure, got: ${failures.join('; ')}`,
);
});
it('should ignore filenames that belong to the user rather than the collection', () => {
writeValidSkill(skillsRoot, 'publish-app');
writeFixtureFile(
skillsRoot,
'publish-app/references/concepts/basics.md',
'Inspect `README.md` and `package.json` in the app.\n',
);
assertNoDanglingDocMentions(skillsRoot, ['publish-app'], fail);
assert.deepEqual(failures, []);
});
});
describe('verify-install argument handling', () => {
let installRoot;
beforeEach(() => {
installRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-skills-install-'),
);
});
afterEach(() => {
fs.rmSync(installRoot, { recursive: true, force: true });
});
it('should not report escaping references when given a relative install root', () => {
const failures = [];
const fail = (message) => failures.push(message);
writeValidSkill(installRoot, 'create-app');
assertSelfContainedReferences({
skillsRoot: path.resolve(installRoot),
skillName: 'create-app',
fail,
});
assert.deepEqual(failures, []);
});
});

View File

@@ -0,0 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
const writeFixtureFile = (root, relativePath, contents) => {
const absolutePath = path.join(root, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, contents);
};
export { writeFixtureFile };

View File

@@ -0,0 +1,355 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import {
rewriteSkillReferenceLinks,
collectSkillSeedReferences,
resolveReferenceClosure,
buildPortableSkills,
buildDistribution,
CANONICAL_SKILL_NAMES,
listFiles,
} from '../lib.js';
import { writeFixtureFile } from './fixtures.js';
describe('rewriteSkillReferenceLinks', () => {
it('should rewrite plugin-relative reference links to skill-relative links', () => {
const rewritten = rewriteSkillReferenceLinks(
'Read `../../references/concepts/how-apps-work.md` and `../../references/develop-app/tests.md`.',
);
assert.equal(
rewritten,
'Read `references/concepts/how-apps-work.md` and `references/develop-app/tests.md`.',
);
});
it('should leave skill-relative links untouched', () => {
const contents = 'Read `references/concepts/how-apps-work.md`.';
assert.equal(rewriteSkillReferenceLinks(contents), contents);
});
});
describe('collectSkillSeedReferences', () => {
it('should collect plugin-relative and skill-relative reference mentions', () => {
const seeds = collectSkillSeedReferences(
'Read `../../references/concepts/how-apps-work.md` then `references/design/front-component-ui.md`.',
);
assert.deepEqual([...seeds].sort(), [
'concepts/how-apps-work.md',
'design/front-component-ui.md',
]);
});
});
describe('resolveReferenceClosure', () => {
let fixtureRoot;
beforeEach(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-skills-lib-'));
});
afterEach(() => {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
it('should follow sibling-scope and same-directory reference mentions transitively', () => {
writeFixtureFile(fixtureRoot, 'references/a/one.md', 'See `../b/two.md`.');
writeFixtureFile(fixtureRoot, 'references/b/two.md', 'See `three.md`.');
writeFixtureFile(fixtureRoot, 'references/b/three.md', 'Terminal doc.');
const closure = resolveReferenceClosure(
path.join(fixtureRoot, 'references'),
new Set(['a/one.md']),
);
assert.deepEqual([...closure].sort(), [
'a/one.md',
'b/three.md',
'b/two.md',
]);
});
it('should ignore backtick tokens that only look like markdown files', () => {
writeFixtureFile(
fixtureRoot,
'references/design/ui.md',
'Use `themeCssVariables.font.size.md` for medium font size.',
);
const closure = resolveReferenceClosure(
path.join(fixtureRoot, 'references'),
new Set(['design/ui.md']),
);
assert.deepEqual([...closure], ['design/ui.md']);
});
it('should throw when a referenced doc does not exist', () => {
writeFixtureFile(
fixtureRoot,
'references/a/one.md',
'See `../b/missing.md`.',
);
assert.throws(
() =>
resolveReferenceClosure(
path.join(fixtureRoot, 'references'),
new Set(['a/one.md']),
),
/referenced doc does not exist: references\/b\/missing\.md/,
);
});
});
describe('buildPortableSkills', () => {
let sourceRoot;
let outputRoot;
beforeEach(() => {
sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-skills-source-'));
outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-skills-output-'));
writeFixtureFile(
sourceRoot,
'skills/demo-skill/SKILL.md',
'---\nname: demo-skill\ndescription: Demo.\n---\n\nRead `../../references/concepts/basics.md`.\n',
);
writeFixtureFile(
sourceRoot,
'skills/demo-skill/agents/openai.yaml',
'interface: {}\n',
);
writeFixtureFile(
sourceRoot,
'references/concepts/basics.md',
'See `../deep/detail.md`.',
);
writeFixtureFile(sourceRoot, 'references/deep/detail.md', 'Terminal doc.');
});
afterEach(() => {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(outputRoot, { recursive: true, force: true });
});
it('should emit a self-contained skill including optional Codex display metadata', () => {
buildPortableSkills({ sourceRoot, outputRoot, skillNames: ['demo-skill'] });
const skillMarkdown = fs.readFileSync(
path.join(outputRoot, 'demo-skill', 'SKILL.md'),
'utf8',
);
assert.match(skillMarkdown, /`references\/concepts\/basics\.md`/);
assert.ok(
fs.existsSync(
path.join(outputRoot, 'demo-skill', 'references/concepts/basics.md'),
),
);
assert.ok(
fs.existsSync(
path.join(outputRoot, 'demo-skill', 'references/deep/detail.md'),
),
);
assert.equal(
fs.readFileSync(
path.join(outputRoot, 'demo-skill', 'agents/openai.yaml'),
'utf8',
),
'interface: {}\n',
);
});
it('should remove stale files from a previous build', () => {
writeFixtureFile(outputRoot, 'stale-skill/SKILL.md', 'stale');
buildPortableSkills({ sourceRoot, outputRoot, skillNames: ['demo-skill'] });
assert.ok(!fs.existsSync(path.join(outputRoot, 'stale-skill')));
});
it('should throw when a canonical skill is missing', () => {
assert.throws(
() =>
buildPortableSkills({
sourceRoot,
outputRoot,
skillNames: ['absent-skill'],
}),
/canonical skill is missing: skills\/absent-skill\/SKILL\.md/,
);
});
});
describe('resolveReferenceClosure cross-scope mentions', () => {
let referencesRoot;
beforeEach(() => {
referencesRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-skills-closure-'),
);
});
afterEach(() => {
fs.rmSync(referencesRoot, { recursive: true, force: true });
});
it('should throw when a bare mention names a doc that lives in another scope', () => {
writeFixtureFile(
referencesRoot,
'design/front-component-ui.md',
'Use `front-components.md` for exact imports.\n',
);
writeFixtureFile(
referencesRoot,
'develop-app/front-components.md',
'Front components.\n',
);
assert.throws(
() =>
resolveReferenceClosure(referencesRoot, [
'design/front-component-ui.md',
]),
/lives at references\/develop-app\/front-components\.md/,
);
});
it('should still ignore bare mentions that name no reference doc', () => {
writeFixtureFile(
referencesRoot,
'publish-app/prepare-for-app-store.md',
'Write the app `README.md` and set `themeCssVariables.font.size.md`.\n',
);
const closure = resolveReferenceClosure(referencesRoot, [
'publish-app/prepare-for-app-store.md',
]);
assert.deepEqual([...closure], ['publish-app/prepare-for-app-store.md']);
});
});
describe('buildDistribution', () => {
let fixtureRoot;
beforeEach(() => {
fixtureRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'twenty-distribution-'),
);
});
afterEach(() => {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
it('builds one standalone plugin and skill collection without modifying source', () => {
const sourceRoot = path.join(fixtureRoot, 'source');
const outputRoot = path.join(fixtureRoot, 'dist');
const files = [
'.codex-plugin',
'.mcp.json',
'assets',
'skills',
'scripts/setup-mcp.sh',
];
writeFixtureFile(
sourceRoot,
'package.json',
JSON.stringify({
name: 'twenty-agent-skills',
version: '0.1.0',
license: 'AGPL-3.0',
files,
scripts: { build: 'node scripts/build.js' },
}),
);
writeFixtureFile(
sourceRoot,
'.codex-plugin/plugin.json',
'{"name":"twenty","skills":"./skills/"}',
);
writeFixtureFile(sourceRoot, '.mcp.json', '{"mcpServers":{}}');
writeFixtureFile(sourceRoot, 'assets/logo.png', 'binary-asset');
writeFixtureFile(sourceRoot, 'scripts/setup-mcp.sh', '#!/bin/sh\nexit 0\n');
fs.chmodSync(path.join(sourceRoot, 'scripts/setup-mcp.sh'), 0o755);
writeFixtureFile(sourceRoot, 'scripts/build.js', 'build-only');
writeFixtureFile(
sourceRoot,
'references/concepts/basics.md',
'Shared concept.',
);
for (const skillName of CANONICAL_SKILL_NAMES) {
writeFixtureFile(
sourceRoot,
`skills/${skillName}/SKILL.md`,
'Read `../../references/concepts/basics.md`.',
);
writeFixtureFile(
sourceRoot,
`skills/${skillName}/agents/openai.yaml`,
'interface: {}\n',
);
}
const snapshot = (root) =>
listFiles(root).map((file) => [
path.relative(root, file),
fs.readFileSync(file),
]);
const sourceBefore = snapshot(sourceRoot);
buildDistribution({ sourceRoot, outputRoot });
const distributionBefore = snapshot(outputRoot);
writeFixtureFile(outputRoot, 'removed-skill/SKILL.md', 'stale');
buildDistribution({ sourceRoot, outputRoot });
assert.deepEqual(snapshot(sourceRoot), sourceBefore);
assert.deepEqual(snapshot(outputRoot), distributionBefore);
assert.ok(
fs.existsSync(path.join(outputRoot, '.codex-plugin/plugin.json')),
);
assert.ok(fs.existsSync(path.join(outputRoot, '.mcp.json')));
assert.ok(fs.existsSync(path.join(outputRoot, 'assets/logo.png')));
assert.ok(!fs.existsSync(path.join(outputRoot, 'scripts/build.js')));
assert.equal(
fs.statSync(path.join(outputRoot, 'scripts/setup-mcp.sh')).mode & 0o777,
0o755,
);
assert.equal(
JSON.parse(fs.readFileSync(path.join(outputRoot, 'package.json'), 'utf8'))
.scripts,
undefined,
);
fs.rmSync(sourceRoot, { recursive: true });
for (const skillName of CANONICAL_SKILL_NAMES) {
const skillRoot = path.join(outputRoot, 'skills', skillName);
assert.equal(
fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8'),
'Read `references/concepts/basics.md`.',
);
assert.equal(
fs.readFileSync(
path.join(skillRoot, 'references/concepts/basics.md'),
'utf8',
),
'Shared concept.',
);
assert.equal(
fs.readFileSync(path.join(skillRoot, 'agents/openai.yaml'), 'utf8'),
'interface: {}\n',
);
assert.ok(
listFiles(skillRoot).every((file) => fs.lstatSync(file).isFile()),
);
}
});
});

View File

@@ -0,0 +1,449 @@
import { test } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as metadata from '../validators/metadata.js';
import * as assets from '../validators/assets.js';
import * as skills from '../validators/skills.js';
import * as references from '../validators/references.js';
import * as crossDocContracts from '../validators/cross-doc-contracts.js';
import * as setupHelper from '../validators/setup-helper.js';
const PLUGIN_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
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 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('assertFrontComponentGuidance catches independent UI version guidance', () => {
const frontComponentsPath = path.join(
PLUGIN_ROOT,
'references/develop-app/front-components.md',
);
withFileMutation(
frontComponentsPath,
(contents) => contents.replace(
'at the same version as `twenty-sdk` and `twenty-client-sdk`',
'at an independent version',
),
() => {
const failures = collectFailures(crossDocContracts.assertFrontComponentGuidance);
assert.ok(failures.some((failure) => failure.includes('same version as')));
},
);
});
test('assertCliGuidanceSplit passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertCliGuidanceSplit), []);
});
test('assertCliGuidanceSplit catches deprecated dev --once guidance', () => {
const cliAndSyncPath = path.join(
PLUGIN_ROOT,
'references',
'manage-app',
'cli-and-sync.md',
);
withFileMutation(
cliAndSyncPath,
(contents) => contents.replace('yarn twenty apply', 'yarn twenty dev --once'),
() => {
const failures = collectFailures(crossDocContracts.assertCliGuidanceSplit);
assert.ok(
failures.some((failure) => failure.includes('yarn twenty dev --once')),
);
},
);
});
test('assertCliGuidanceSplit catches deprecated guidance outside the CLI reference', () => {
const manageSkillPath = path.join(
PLUGIN_ROOT,
'skills',
'manage-app',
'SKILL.md',
);
withFileMutation(
manageSkillPath,
(contents) => `${contents}\nRun yarn twenty dev --once after editing.\n`,
() => {
const failures = collectFailures(crossDocContracts.assertCliGuidanceSplit);
assert.ok(
failures.some(
(failure) =>
failure.includes('skills/manage-app/SKILL.md') &&
failure.includes('yarn twenty dev --once'),
),
);
},
);
});
test('assertCliGuidanceSplit catches stale one-shot sync terminology', () => {
const standalonePagesPath = path.join(
PLUGIN_ROOT,
'references',
'develop-app',
'standalone-pages.md',
);
withFileMutation(
standalonePagesPath,
(contents) => `${contents}\nUse one-shot sync for verification.\n`,
() => {
const failures = collectFailures(crossDocContracts.assertCliGuidanceSplit);
assert.ok(
failures.some(
(failure) =>
failure.includes('references/develop-app/standalone-pages.md') &&
failure.includes('one-shot sync'),
),
);
},
);
});
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('source scans ignore generated distribution files', () => {
const distributionRoot = path.join(PLUGIN_ROOT, 'dist');
fs.mkdirSync(distributionRoot, { recursive: true });
const fixtureRoot = fs.mkdtempSync(path.join(distributionRoot, 'source-validation-'));
try {
fs.writeFileSync(path.join(fixtureRoot, '.mcp.json'), '{}');
fs.writeFileSync(path.join(fixtureRoot, 'SKILL.md'), 'name: create-an-app\n');
assert.deepStrictEqual(collectFailures(metadata.assertNoBundledMcpConfig), []);
assert.deepStrictEqual(collectFailures(skills.assertNoLegacySkillReferences), []);
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
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 requires marketplace identity', () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (template) => {
delete template.name;
delete template.interface;
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((failure) => failure.includes('name must be a non-empty string')));
assert.ok(failures.some((failure) => failure.includes('interface.displayName must be a non-empty string')));
});
});
test('assertMarketplaceTemplate catches the legacy source descriptor', () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (template) => {
template.plugins[0].source = {
type: 'local',
path: './packages/twenty-agent-skills/dist',
};
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((failure) => failure.includes('source.source must be local')));
});
});
for (const [field, value] of [['installation', 'manual'], ['authentication', 'user-local']]) {
test(`assertMarketplaceTemplate catches legacy ${field} policy`, () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (template) => {
template.plugins[0].policy[field] = value;
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((failure) => failure.includes(`policy.${field} must be`)));
});
});
}
test('assertMarketplaceTemplate requires the installable distribution path', () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (template) => {
template.plugins[0].source.path = './packages/twenty-agent-skills';
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((failure) => failure.includes('source.path must be ./packages/twenty-agent-skills/dist')));
});
});
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('; ')}`,
);
});
});
test('assertOperatingRulesSingleSource passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertOperatingRulesSingleSource), []);
});
test('assertOperatingRulesSingleSource catches AGENTS.md restating a rule', () => {
const agentsPath = path.join(PLUGIN_ROOT, 'AGENTS.md');
withFileMutation(agentsPath, (original) => `${original}\n1. **Confirm destructive operations.** Restated.\n`, () => {
const failures = collectFailures(crossDocContracts.assertOperatingRulesSingleSource);
assert.ok(
failures.some((f) => f.includes('AGENTS.md') && f.includes('restates an operating rule')),
`expected a restated-rule failure, got: ${failures.join('; ')}`,
);
});
});
test('assertOperatingRulesSingleSource catches AGENTS.md losing the pointer', () => {
const agentsPath = path.join(PLUGIN_ROOT, 'AGENTS.md');
withFileMutation(agentsPath, (original) => original.replaceAll('references/concepts/operating-rules.md', 'nowhere.md'), () => {
const failures = collectFailures(crossDocContracts.assertOperatingRulesSingleSource);
assert.ok(
failures.some((f) => f.includes('AGENTS.md must point at')),
`expected a missing-pointer failure, got: ${failures.join('; ')}`,
);
});
});
test('assertHowAppsWork catches a skill that does not reference operating-rules.md', () => {
const skillPath = path.join(PLUGIN_ROOT, 'skills', 'develop-app', 'SKILL.md');
withFileMutation(skillPath, (original) => original.replace('../../references/concepts/operating-rules.md', 'nowhere.md'), () => {
const failures = collectFailures(references.assertHowAppsWork);
assert.ok(
failures.some((f) => f.includes('develop-app/SKILL.md') && f.includes('operating-rules.md')),
`expected an operating-rules reference failure, got: ${failures.join('; ')}`,
);
});
});
test('assertOperatingRulesSingleSource catches prose copied from the rules file', () => {
const agentsPath = path.join(PLUGIN_ROOT, 'AGENTS.md');
const copiedSentence = 'Watch mode leaks file handles and produces ambiguous failure output in agent sandboxes.';
withFileMutation(agentsPath, (original) => `${original}\n${copiedSentence}\n`, () => {
const failures = collectFailures(crossDocContracts.assertOperatingRulesSingleSource);
assert.ok(
failures.some((f) => f.includes('AGENTS.md') && f.includes('copies a sentence')),
`expected a copied-prose failure, got: ${failures.join('; ')}`,
);
});
});

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { buildDistribution } from './lib.js';
buildDistribution();
console.log('Twenty skills and Codex plugin distribution built in dist/.');

View File

@@ -0,0 +1,213 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CANONICAL_SKILL_NAMES,
readText,
listFiles,
} from './validators/lib.js';
const PACKAGE_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const DISTRIBUTION_ROOT = path.join(PACKAGE_ROOT, 'dist');
const SKILL_REFERENCE_PATTERN =
/(?:\.\.\/\.\.\/)?references\/([A-Za-z0-9._/-]+\.md)/g;
const SIBLING_REFERENCE_PATTERN =
/\.\.\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+\.md)/g;
const SAME_DIRECTORY_REFERENCE_PATTERN = /`([A-Za-z0-9._-]+\.md)`/g;
const findReferenceByBasename = ({ referencesRoot, basename }) => {
for (const filePath of listFiles(referencesRoot)) {
if (path.basename(filePath) === basename) {
return path.relative(referencesRoot, filePath).split(path.sep).join('/');
}
}
return undefined;
};
const rewriteSkillReferenceLinks = (skillMarkdown) =>
skillMarkdown.replaceAll('../../references/', 'references/');
const collectSkillSeedReferences = (skillMarkdown) => {
const seeds = new Set();
for (const match of skillMarkdown.matchAll(SKILL_REFERENCE_PATTERN)) {
seeds.add(match[1]);
}
return seeds;
};
// Expands the seed set with every reference doc reachable through relative
// mentions (`../<scope>/<file>.md` or same-directory `<file>.md`) so an
// installed skill never points at a file that was not copied with it.
const resolveReferenceClosure = (referencesRoot, seedReferences) => {
const closure = new Set();
const queue = [...seedReferences];
while (queue.length > 0) {
const relativeReference = queue.shift();
if (closure.has(relativeReference)) {
continue;
}
const absolutePath = path.join(referencesRoot, relativeReference);
if (!fs.existsSync(absolutePath)) {
throw new Error(
`referenced doc does not exist: references/${relativeReference}`,
);
}
closure.add(relativeReference);
const contents = readText(absolutePath);
const scope = path.dirname(relativeReference);
for (const match of contents.matchAll(SIBLING_REFERENCE_PATTERN)) {
queue.push(path.posix.join(match[1], match[2]));
}
for (const match of contents.matchAll(SAME_DIRECTORY_REFERENCE_PATTERN)) {
const candidate = path.posix.join(scope, match[1]);
// Backtick mentions also match non-file tokens such as
// `themeCssVariables.font.size.md`; only real sibling docs are queued.
if (fs.existsSync(path.join(referencesRoot, candidate))) {
queue.push(candidate);
continue;
}
// A bare mention that names a reference doc living in another scope would
// otherwise be dropped here, shipping a skill that points at a file it
// does not carry. Make the author write the explicit relative path.
const elsewhere = findReferenceByBasename({
referencesRoot,
basename: match[1],
});
if (elsewhere) {
throw new Error(
`references/${relativeReference} mentions \`${match[1]}\` as a same-directory file, but it lives at references/${elsewhere}. Use an explicit relative path such as \`${path.posix.relative(scope, elsewhere)}\`.`,
);
}
}
}
return closure;
};
const buildPortableSkills = ({
sourceRoot = PACKAGE_ROOT,
outputRoot,
skillNames = CANONICAL_SKILL_NAMES,
}) => {
const referencesRoot = path.join(sourceRoot, 'references');
fs.rmSync(outputRoot, { recursive: true, force: true });
for (const skillName of skillNames) {
const sourceSkillPath = path.join(
sourceRoot,
'skills',
skillName,
'SKILL.md',
);
if (!fs.existsSync(sourceSkillPath)) {
throw new Error(
`canonical skill is missing: skills/${skillName}/SKILL.md`,
);
}
const skillMarkdown = readText(sourceSkillPath);
const outputSkillRoot = path.join(outputRoot, skillName);
fs.cpSync(path.dirname(sourceSkillPath), outputSkillRoot, {
recursive: true,
});
fs.writeFileSync(
path.join(outputSkillRoot, 'SKILL.md'),
rewriteSkillReferenceLinks(skillMarkdown),
);
const seedReferences = collectSkillSeedReferences(skillMarkdown);
const referenceClosure = resolveReferenceClosure(
referencesRoot,
seedReferences,
);
for (const relativeReference of [...referenceClosure].sort()) {
const outputReferencePath = path.join(
outputSkillRoot,
'references',
relativeReference,
);
fs.mkdirSync(path.dirname(outputReferencePath), { recursive: true });
fs.copyFileSync(
path.join(referencesRoot, relativeReference),
outputReferencePath,
);
}
}
};
const buildDistribution = ({
sourceRoot = PACKAGE_ROOT,
outputRoot = DISTRIBUTION_ROOT,
} = {}) => {
const packageJson = JSON.parse(
readText(path.join(sourceRoot, 'package.json')),
);
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.mkdirSync(outputRoot, { recursive: true });
for (const relativePath of packageJson.files) {
if (relativePath !== 'skills') {
fs.cpSync(
path.join(sourceRoot, relativePath),
path.join(outputRoot, relativePath),
{
recursive: true,
},
);
}
}
buildPortableSkills({
sourceRoot,
outputRoot: path.join(outputRoot, 'skills'),
});
const { name, version, description, license, files } = packageJson;
fs.writeFileSync(
path.join(outputRoot, 'package.json'),
JSON.stringify(
{ name, version, description, license, private: true, files },
null,
2,
) + '\n',
);
};
export {
PACKAGE_ROOT,
DISTRIBUTION_ROOT,
CANONICAL_SKILL_NAMES,
readText,
listFiles,
rewriteSkillReferenceLinks,
findReferenceByBasename,
collectSkillSeedReferences,
resolveReferenceClosure,
buildPortableSkills,
buildDistribution,
};

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

View File

@@ -0,0 +1,365 @@
#!/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,
};

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env node
import * as metadata from './validators/metadata.js';
import * as assets from './validators/assets.js';
import * as skills from './validators/skills.js';
import * as references from './validators/references.js';
import * as crossDocContracts from './validators/cross-doc-contracts.js';
import * as setupHelper from './validators/setup-helper.js';
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.assertOperatingRulesSingleSource(fail);
crossDocContracts.assertTwentyMcpFormattingContract(fail);
crossDocContracts.assertFrontComponentGuidance(fail);
crossDocContracts.assertCliGuidanceSplit(fail);
crossDocContracts.assertTestingGuidance(fail);
setupHelper.assertSetupHelper(fail);
if (failures.length > 0) {
console.error('Twenty Agent Skills source validation failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('Twenty Agent Skills source validation passed.');

View File

@@ -0,0 +1,81 @@
import fs from 'node:fs';
import path from 'node:path';
import {
MIN_LOGO_DIMENSION,
readText,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
} from './lib.js';
const assertAssets = (fail) => {
const { readJson } = createJsonReaders(fail);
const resolveInterfacePath = createInterfacePathResolver(fail);
const pluginJson = readJson('packages/twenty-agent-skills/.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}`);
}
}
}
};
export { assertAssets };

View File

@@ -0,0 +1,465 @@
import fs from 'node:fs';
import path from 'node:path';
import { PLUGIN_ROOT, listFiles, readText } from './lib.js';
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`',
'Install `twenty-ui` from npm at the same version as `twenty-sdk` and `twenty-client-sdk`',
'import Twenty UI components, icons, and theme tokens from its subpaths',
'ThemeProvider',
'showcase/twenty-ui-example.front-component.tsx',
'themeCssVariables',
'mocks `twenty-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.VERTICAL_LIST',
"heightBehavior: 'TAB_VIEWPORT'",
'owns internal scrolling',
'Full-Page Layout Guidance',
'black screen',
'yarn twenty apply',
];
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 apply',
];
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 apply',
'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 apply',
];
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',
'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 plan',
'yarn twenty apply',
'`yarn twenty plan` is read-only and requires the app to already be installed',
'For a first sync, run `yarn twenty apply`',
'Use the bounded apply command to synchronize app changes with the active remote',
'Do not use bare `yarn twenty dev` (watch mode)',
'yarn twenty apply --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',
'yarn twenty dev --once',
];
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 agentFacingDocumentPaths = [
path.join(PLUGIN_ROOT, 'AGENTS.md'),
...listFiles(path.join(PLUGIN_ROOT, 'skills')).filter((filePath) =>
filePath.endsWith('.md'),
),
...listFiles(path.join(PLUGIN_ROOT, 'references')).filter((filePath) =>
filePath.endsWith('.md'),
),
];
for (const documentPath of agentFacingDocumentPaths) {
const contents = readText(documentPath);
for (const deprecatedFragment of [
'yarn twenty dev --once',
'one-shot sync',
]) {
if (contents.toLowerCase().includes(deprecatedFragment)) {
fail(
`${path.relative(PLUGIN_ROOT, documentPath)} contains deprecated ${deprecatedFragment} guidance`,
);
}
}
}
};
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 operatingRulesPath = path.join(PLUGIN_ROOT, 'references/concepts/operating-rules.md');
const manageSkill = readText(manageSkillPath);
const tests = readText(testsPath);
const cliAndSync = readText(cliAndSyncPath);
const operatingRules = readText(operatingRulesPath);
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 requiredOperatingRulesFragments = [
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Integration tests must target the isolated test instance on port `2021`',
];
for (const fragment of requiredOperatingRulesFragments) {
if (!operatingRules.includes(fragment)) {
fail(`operating-rules.md is missing durable test target guidance: ${fragment}`);
}
}
};
const OPERATING_RULE_HEADLINES = [
'**Bounded sync only.**',
'**Do not run broad validation unless it is requested.**',
'**Use `yarn twenty dev:add` for new entities.**',
'**Confirm destructive operations.**',
'**Any Twenty instance is a valid target.**',
'**Workspace URLs and credentials stay user-local.**',
];
// Every entry point links to the operating rules to keep authored guidance
// from drifting between skills and the Codex wrapper.
const assertOperatingRulesSingleSource = (fail) => {
const operatingRulesPath = path.join(PLUGIN_ROOT, 'references/concepts/operating-rules.md');
if (!fs.existsSync(operatingRulesPath)) {
fail('required reference is missing: references/concepts/operating-rules.md');
return;
}
const operatingRules = readText(operatingRulesPath);
for (const headline of OPERATING_RULE_HEADLINES) {
if (!operatingRules.includes(headline)) {
fail(`operating-rules.md is missing a durable rule: ${headline}`);
}
}
const restatingDocumentPaths = [
path.join(PLUGIN_ROOT, 'AGENTS.md'),
...listFiles(path.join(PLUGIN_ROOT, 'skills')).filter((filePath) => filePath.endsWith('.md')),
];
for (const documentPath of restatingDocumentPaths) {
const relativePath = path.relative(PLUGIN_ROOT, documentPath);
const contents = readText(documentPath);
for (const headline of OPERATING_RULE_HEADLINES) {
if (contents.includes(headline)) {
fail(`${relativePath} restates an operating rule instead of linking to operating-rules.md: ${headline}`);
}
}
}
const agents = readText(path.join(PLUGIN_ROOT, 'AGENTS.md'));
if (!agents.includes('references/concepts/operating-rules.md')) {
fail('AGENTS.md must point at references/concepts/operating-rules.md for the durable operating rules');
}
// Headlines catch a copied rule list. Copied prose is the other way a second
// version of a rule appears and then drifts, so sentences from the rule
// bodies must not be pasted into an entry point either. A skill applying a
// rule in its own words at the point of use is expected and not flagged.
const ruleBodySentences = operatingRules
.split('\n')
.filter((line) => /^\d+\.\s+\*\*/.test(line))
.flatMap((line) => line.replace(/^\d+\.\s+\*\*[^*]+\*\*\s*/, '').split('. '))
.map((sentence) => sentence.trim())
.filter((sentence) => sentence.length >= 60);
for (const documentPath of restatingDocumentPaths) {
const relativePath = path.relative(PLUGIN_ROOT, documentPath);
const contents = readText(documentPath);
for (const sentence of ruleBodySentences) {
if (contents.includes(sentence)) {
fail(`${relativePath} copies a sentence from operating-rules.md instead of linking to it: "${sentence.slice(0, 60)}..."`);
}
}
}
};
export {
OPERATING_RULE_HEADLINES,
assertOperatingRulesSingleSource,
assertTwentyMcpFormattingContract,
assertFrontComponentGuidance,
assertCliGuidanceSplit,
assertTestingGuidance,
};

View File

@@ -0,0 +1,216 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PLUGIN_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
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 CANONICAL_SKILL_NAMES = [
'create-app',
'develop-app',
'manage-app',
'publish-app',
'use-twenty-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);
// Generated bundles have their own validation and may be stale during edits.
if (absolutePath === path.join(PLUGIN_ROOT, 'dist')) {
continue;
}
if (entry.isDirectory()) {
files.push(...listFiles(absolutePath));
} else {
files.push(absolutePath);
}
}
// Sorted so validator output is stable regardless of readdir order.
return files.sort();
};
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 === 'skills.sh' ||
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;
};
export {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
CANONICAL_SKILL_NAMES,
LEGACY_SKILL_NAMES,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
MIN_LOGO_DIMENSION,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
isAllowedDocumentationHost,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
};

View File

@@ -0,0 +1,264 @@
import fs from 'node:fs';
import path from 'node:path';
import {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
readText,
listFiles,
isAllowedDocumentationHost,
createJsonReaders,
} from './lib.js';
const assertJsonMetadata = (fail) => {
const { readJson, readOptionalJson } = createJsonReaders(fail);
const packageJson = readJson('packages/twenty-agent-skills/package.json');
const pluginJson = readJson('packages/twenty-agent-skills/.codex-plugin/plugin.json');
const mcpJson = readJson('packages/twenty-agent-skills/.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-agent-skills/dist') {
fail('marketplace twenty source path must be ./packages/twenty-agent-skills/dist');
}
if (fs.existsSync(path.join(REPO_ROOT, 'plugins', 'twenty'))) {
fail('legacy plugins/twenty path must not exist; use packages/twenty-agent-skills/dist');
}
};
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-agent-skills/.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-agent-skills/.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-agent-skills/templates/marketplace.example.json';
const template = readOptionalJson(templatePath);
const pluginJson = readJson('packages/twenty-agent-skills/.codex-plugin/plugin.json');
if (!template) {
fail(`marketplace template is missing at ${templatePath}`);
return;
}
if (typeof template.name !== 'string' || template.name.trim().length === 0) {
fail(`${templatePath} name must be a non-empty string`);
}
if (typeof template.interface?.displayName !== 'string' || template.interface.displayName.trim().length === 0) {
fail(`${templatePath} interface.displayName must be a non-empty string`);
}
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.source?.source !== 'local') {
fail(`${templatePath} twenty.source.source must be local`);
}
if (twentyEntry.source?.path !== './packages/twenty-agent-skills/dist') {
fail(`${templatePath} twenty.source.path must be ./packages/twenty-agent-skills/dist`);
}
if (!['NOT_AVAILABLE', 'AVAILABLE', 'INSTALLED_BY_DEFAULT'].includes(twentyEntry.policy?.installation)) {
fail(`${templatePath} twenty.policy.installation must be NOT_AVAILABLE, AVAILABLE, or INSTALLED_BY_DEFAULT`);
}
if (!['ON_INSTALL', 'ON_USE'].includes(twentyEntry.policy?.authentication)) {
fail(`${templatePath} twenty.policy.authentication must be ON_INSTALL or ON_USE`);
}
if (twentyEntry.category !== pluginJson?.interface?.category) {
fail(`${templatePath} twenty.category must match plugin.json interface.category`);
}
};
export {
assertJsonMetadata,
assertNoBundledMcpConfig,
assertInterfaceFields,
assertMarketplaceTemplate,
};

View File

@@ -0,0 +1,104 @@
import fs from 'node:fs';
import path from 'node:path';
import { PLUGIN_ROOT, readText } from './lib.js';
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/concepts/operating-rules.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`);
}
if (!skill.includes('references/concepts/operating-rules.md')) {
fail(`${skillRelPath} must reference operating-rules.md`);
}
}
};
export {
REQUIRED_REFERENCES,
assertReferences,
assertHowAppsWork,
};

View File

@@ -0,0 +1,48 @@
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { PLUGIN_ROOT } from './lib.js';
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}`);
}
}
};
export { assertSetupHelper };

View File

@@ -0,0 +1,176 @@
import fs from 'node:fs';
import path from 'node:path';
import {
PLUGIN_ROOT,
CANONICAL_SKILL_NAMES,
LEGACY_SKILL_NAMES,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
} from './lib.js';
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 CANONICAL_SKILL_NAMES) {
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}`,
);
}
}
}
};
export {
CANONICAL_SKILL_NAMES,
assertSkills,
assertSkillTriggerPhrases,
assertNoLegacySkillReferences,
};

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import {
DISTRIBUTION_ROOT,
CANONICAL_SKILL_NAMES,
listFiles,
readText,
} from './lib.js';
import {
assertSelfContainedReferences,
assertSkillFrontmatter,
} from './validate-distribution.js';
const [installRoot, ...requestedSkills] = process.argv.slice(2);
if (!installRoot) {
console.error(
'usage: verify-install.js <install-root> [skill-name...]\n' +
'Verifies that skills installed by the `skills` CLI are complete and self-contained.',
);
process.exit(1);
}
const skillNames =
requestedSkills.length > 0 ? requestedSkills : CANONICAL_SKILL_NAMES;
// assertSelfContainedReferences resolves references to absolute paths, so the
// roots it compares them against must be absolute too.
const absoluteInstallRoot = path.resolve(installRoot);
// `skills add` writes to an agent-specific directory (.agents/skills, .claude/skills,
// .codex/skills, ...) and may symlink between them, so the installed copies are
// discovered rather than assumed.
const findInstalledSkillRoots = ({ root, skillName }) => {
const found = [];
const walk = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (
!fs.existsSync(absolutePath) ||
!fs.statSync(absolutePath).isDirectory()
) {
continue;
}
if (
entry.name === skillName &&
fs.existsSync(path.join(absolutePath, 'SKILL.md'))
) {
found.push(absolutePath);
continue;
}
walk(absolutePath);
}
};
walk(root);
return found;
};
const relativeFiles = (root) =>
listFiles(root)
.map((filePath) => path.relative(root, filePath))
.sort();
const failures = [];
const fail = (message) => failures.push(message);
for (const skillName of skillNames) {
const sourceRoot = path.join(DISTRIBUTION_ROOT, 'skills', skillName);
const installedRoots = findInstalledSkillRoots({
root: absoluteInstallRoot,
skillName,
});
if (installedRoots.length === 0) {
fail(
`${skillName} was not installed anywhere under ${absoluteInstallRoot}`,
);
continue;
}
const expectedFiles = relativeFiles(sourceRoot);
for (const installedRoot of installedRoots) {
const location = path.relative(absoluteInstallRoot, installedRoot);
const installedFiles = relativeFiles(installedRoot);
for (const relativePath of expectedFiles) {
if (!installedFiles.includes(relativePath)) {
fail(`${location} is missing ${relativePath}`);
continue;
}
if (
readText(path.join(sourceRoot, relativePath)) !==
readText(path.join(installedRoot, relativePath))
) {
fail(`${location} installed a modified ${relativePath}`);
}
}
for (const relativePath of installedFiles) {
if (!expectedFiles.includes(relativePath)) {
fail(`${location} has an unexpected ${relativePath}`);
}
}
// Every relative link inside every installed file must resolve inside the
// installed directory; that is the "no manual fixes after installation" bar.
assertSelfContainedReferences({
skillsRoot: path.dirname(installedRoot),
skillName,
fail,
});
assertSkillFrontmatter({
skillsRoot: path.dirname(installedRoot),
skillName,
fail,
});
console.log(
`${skillName}: ${installedFiles.length} files verified in ${location}`,
);
}
}
if (failures.length > 0) {
console.error('Installed Twenty agent skills failed verification:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(`Verified ${skillNames.length} installed skill(s).`);