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,47 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-present Twenty.com, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,58 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
The official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). Sets up a ready-to-run project with [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
yarn twenty dev
```
The scaffolder will:
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
2. Start a local Twenty server via Docker (pulls the latest image automatically)
3. Authenticate with the development API key
## Options
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
- [Quick Start](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start) — scaffold, run a local server, sync your code
- [Concepts](https://docs.twenty.com/developers/extend/apps/getting-started/concepts) — how apps work: entity model, sandboxing, lifecycle
- [Operations](https://docs.twenty.com/developers/extend/apps/operations/overview) — CLI, testing, CI, deploy and publish
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,47 @@
const jestConfig = {
displayName: 'create-twenty-app',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^package.json$': '<rootDir>/package.json',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
// The scaffold template ships its own tests that run under the generated
// project's vitest, not this package's jest, so ignore the template directory.
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/src/constants/template/',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli.ts', // Exclude CLI entry point from coverage
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;

View File

@@ -0,0 +1,62 @@
{
"name": "create-twenty-app",
"version": "2.42.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist",
"README.md",
"package.json"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"exports": {
".": {
"types": "./dist/cli.d.ts",
"import": "./dist/cli.mjs",
"require": "./dist/cli.cjs"
}
},
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.2"
},
"devDependencies": {
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/fs-extra": "^11.0.0",
"@types/jest": "^30.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"yaml": "^2.9.0"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}

View File

@@ -0,0 +1,53 @@
{
"name": "create-twenty-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:create-app"],
"targets": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/cli.ts"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "node dist/cli.cjs"
}
},
"set-local-version": {},
"generate-template-lock": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/scripts/generate-template-lock.ts --version={args.releaseVersion} --registry={args.registry}"
}
},
"typecheck": {},
"lint": {},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
}
}
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import {
type AuthenticationMethod,
CreateAppCommand,
} from '@/create-app.command';
import packageJson from '../package.json';
const program = new Command(packageJson.name)
.description('CLI tool to initialize a new Twenty application')
.version(
packageJson.version,
'-v, --version',
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
directory?: string,
options?: {
name?: string;
displayName?: string;
description?: string;
url?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
if (options?.name !== undefined && options.name.trim().length === 0) {
console.error(chalk.red('Error: --name cannot be empty.'));
process.exit(1);
}
if (
options?.authenticationMethod &&
!['oauth', 'apiKey'].includes(options.authenticationMethod)
) {
console.error(
chalk.red(
'Error: --authentication-method must be "oauth" or "apiKey".',
),
);
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
authenticationMethod: options?.authenticationMethod,
});
},
);
program.exitOverride();
try {
program.parse();
} catch (error) {
if (error instanceof CommanderError) {
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}

View File

@@ -0,0 +1,9 @@
// twenty-client-sdk, twenty-sdk and twenty-ui are released together at a single
// version, so the scaffolder pins all of them to its own version. Keep this list
// as the one source of truth: the template lockfile generator resolves exactly
// these names, and a mismatch between the two silently ships an unusable lockfile.
export const TEMPLATE_FIRST_PARTY_PACKAGES = [
'twenty-client-sdk',
'twenty-sdk',
'twenty-ui',
] as const;

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,7 @@
# Changelog
All notable changes to this application are documented in this file.
## 0.1.0
- Initial application scaffolded with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app)

View File

@@ -0,0 +1,34 @@
# My Twenty App
Describe your app in one or two sentences.
## Features
List the top things your app does, for example:
- Feature one
- Feature two
- Feature three
## Getting started
Setup instructions live in [SETUP.md](SETUP.md).
## Publishing
The `Publish` workflow (`.github/workflows/publish.yml`) publishes the app to npm with provenance using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers). To publish:
1. On npmjs.com register this repository as a trusted publisher of your package, pointing at the `publish.yml` workflow.
2. Bump the version in `package.json`, then push a version tag (e.g. `git tag v1.0.0 && git push --tags`) or run the workflow manually from the Actions tab.
Publishing with provenance is also how you prove ownership when claiming your app in a Twenty marketplace.
## Changelog
Notable changes are documented in [CHANGELOG.md](CHANGELOG.md).
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,47 @@
# Setup
Follow these steps to get your app running locally.
## Prerequisites
- Node.js (version specified in `.nvmrc`)
- Yarn 4
- Docker (to run the local Twenty server)
## Steps
1. Install dependencies:
```bash
yarn install
```
The committed `yarn.lock` pins exact, integrity-checked versions, so this
resolves nothing from the registry.
2. Start the local Twenty server:
```bash
yarn twenty docker:start
```
Check the server status at any time with `yarn twenty docker:status`.
3. Start the development server and sync your app:
```bash
yarn twenty dev
```
4. Open [http://localhost:2020](http://localhost:2020) and log in with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
## Verifying your setup
- `yarn lint` - Lint the project with oxlint
- `yarn typecheck` - Type-check the project
- `yarn test:unit` - Run unit tests
- `yarn test` - Run integration tests
## Troubleshooting
See the [troubleshooting guide](https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting) or ask on [Discord](https://discord.gg/cx5n4Jzs57).

View File

@@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}

View File

@@ -0,0 +1,57 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Lint
run: yarn lint
- name: Typecheck
run: yarn typecheck
- name: Unit tests
run: yarn test:unit
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View File

@@ -0,0 +1,52 @@
# Publishes the app to npm with provenance (npm trusted publishing).
#
# One-time setup:
# 1. On npmjs.com open your package > Settings > Trusted Publisher and register
# this repository and this workflow file (publish.yml).
# See https://docs.npmjs.com/trusted-publishers
# 2. Push a version tag (e.g. `git tag v1.0.0 && git push --tags`) or run the
# workflow manually from the Actions tab.
#
# Publishing with provenance certifies which GitHub repository the package is
# built from. This is also how you claim ownership of your app in a Twenty
# marketplace: the claim flow verifies you own the GitHub account or
# organization the provenance points to.
name: Publish
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
# Required for npm trusted publishing (OIDC provenance).
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Update npm
# Trusted publishing requires npm 11.5.1 or later.
run: npm install -g npm@latest
- name: Install dependencies
run: yarn install --immutable
- name: Publish to npm
# Uncomment if publishing from a private source repo: npm rejects
# OIDC provenance for private repos
# env:
# TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,36 @@
{
"name": "TO-BE-GENERATED",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "TO-BE-GENERATED",
"twenty-sdk": "TO-BE-GENERATED",
"twenty-ui": "TO-BE-GENERATED",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -0,0 +1,14 @@
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('application identifiers', () => {
it('should expose the application metadata constants', () => {
expect(APP_DISPLAY_NAME).toBeTruthy();
expect(typeof APP_DESCRIPTION).toBe('string');
expect(APPLICATION_UNIVERSAL_IDENTIFIER).toBeTruthy();
});
});

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View File

@@ -0,0 +1,13 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
});

View File

@@ -0,0 +1,11 @@
export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';

View File

@@ -0,0 +1,16 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View File

@@ -0,0 +1,287 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import { Avatar } from 'twenty-ui/primitives/data-display';
import {
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-ui/icon';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size="20px" />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size="20px" />;
}
if (title === 'Layout') {
return <IconLayout color={color} size="20px" />;
}
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<IconBox color={color} size="20px" />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
<Avatar name={APP_DISPLAY_NAME} colorSeed={APP_DISPLAY_NAME} size="xl" />
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});

View File

@@ -0,0 +1,19 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,40 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});

View File

@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,31 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});

View File

@@ -0,0 +1,14 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,675 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import kebabCase from 'lodash.kebabcase';
import * as path from 'path';
import { basename } from 'path';
import { spawn } from 'node:child_process';
import {
authLogin,
authLoginOAuth,
checkDockerRunning,
ConfigService,
DEV_API_KEY,
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
} from '@/utils/docker-install';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
const IMAGE = 'twentycrm/twenty-app-dev:latest';
export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
export class CreateAppCommand {
private stepCounter = 0;
private totalSteps = 0;
async execute(options: CreateAppOptions = {}): Promise<void> {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
process.exit(1);
}
if (skipLocalInstance && options.authenticationMethod === 'apiKey') {
console.log(
chalk.yellow(
'API key authentication is only supported on a local Docker instance. Ignoring and switching to OAuth authentication.',
),
);
}
const authenticationMethod = skipLocalInstance
? 'oauth'
: (options.authenticationMethod ?? 'apiKey');
try {
await this.validateDirectory(appDirectory);
this.totalSteps = this.computeTotalSteps({
skipLocalInstance,
});
this.stepCounter = 0;
const dockerPullPromise =
!skipLocalInstance && checkDockerRunning()
? this.pullImageInBackground()
: Promise.resolve(false);
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
this.logNextStep('Creating project directory');
await fs.ensureDir(appDirectory);
this.logDetail(appDirectory);
this.logNextStep('Scaffolding project files');
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
this.logNextStep('Installing dependencies');
await install(appDirectory, (message) => this.logDetail(message));
this.logNextStep('Initializing Git repository');
const gitInitialized = await tryGitInit(appDirectory);
if (gitInitialized) {
this.logDetail('Initialized on branch main');
this.logDetail('Created initial commit');
} else {
this.logDetail(
'Skipped (Git unavailable, initialization failed, or already in a repository)',
);
}
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
this.logNextStep('Starting Twenty server');
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
}
}
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private computeTotalSteps({
skipLocalInstance,
}: {
skipLocalInstance: boolean;
}): number {
let steps = 4; // directory, scaffold, install, git
if (!skipLocalInstance) {
steps += 1; // start server
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
private getAppInfos(options: CreateAppOptions): {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
} {
const appName = (
options.name ??
options.directory ??
'my-twenty-app'
).trim();
const appDisplayName =
options.displayName?.trim() || convertToLabel(appName);
const appDescription = (options.description ?? '').trim();
const appDirectory = options.directory
? path.join(CURRENT_EXECUTION_DIRECTORY, options.directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logPlan({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}): void {
console.log(chalk.blue('\nCreating Twenty Application\n'));
console.log(chalk.white(` Name: ${appName}`));
console.log(chalk.white(` Display name: ${appDisplayName}`));
if (appDescription) {
console.log(chalk.white(` Description: ${appDescription}`));
}
console.log(chalk.white(` Directory: ${appDirectory}`));
console.log('');
}
private logNextStep(title: string): void {
this.stepCounter++;
console.log(
chalk.blue(`\n[${this.stepCounter}/${this.totalSteps}]`) +
chalk.white(` ${title}...`),
);
}
private logDetail(message: string): void {
console.log(chalk.gray(` → ${message}`));
}
private pullImageInBackground(): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('docker', ['pull', IMAGE], { stdio: 'ignore' });
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async ensureDockerServer(
dockerPullPromise: Promise<boolean>,
): Promise<{ url?: string }> {
if (!checkDockerRunning()) {
console.log(
chalk.yellow(
'\n Docker is installed but not running.\n' +
' Please start Docker and run this command again.\n',
),
);
return {};
}
this.logDetail('Ensuring latest Twenty server image...');
const pullSucceeded = await dockerPullPromise;
if (!pullSucceeded) {
this.logDetail(
'Image pull failed, continuing with cached image if available...',
);
}
const startResult = await serverStart({
onProgress: (message: string) => this.logDetail(message),
});
if (startResult.success) {
return { url: startResult.data.url };
}
console.log(chalk.yellow(`\n ${startResult.error.message}`));
return {};
}
private async openMainPage(
appDirectory: string,
serverUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
serverUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
remote: 'local',
});
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote('local');
this.logDetail('Authenticated as tim@apple.dev (development API key)');
return true;
}
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add` manually.',
),
);
return false;
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add` manually.',
),
);
return false;
}
}
private deriveRemoteName(url: string): string {
try {
return new URL(url).hostname.replace(/\./g, '-');
} catch {
return 'remote';
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
return true;
}
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
return false;
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
return false;
}
}
private logSuccess(
appDirectory: string,
serverUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
console.log(chalk.green('\n✔ Application created successfully!\n'));
console.log(chalk.white(' Next steps:\n'));
let stepNumber = 1;
console.log(chalk.white(` ${stepNumber}. Navigate to your project`));
console.log(chalk.cyan(` cd ${dirName}\n`));
stepNumber++;
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
);
stepNumber++;
}
console.log(chalk.white(` ${stepNumber}. Start developing`));
console.log(chalk.cyan(' yarn twenty dev\n'));
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(
chalk.gray(
' Documentation: https://docs.twenty.com/developers/extend/apps/getting-started/quick-start',
),
);
}
}

View File

@@ -0,0 +1,255 @@
// Generates the yarn.lock shipped inside the scaffold template.
//
// Without it, a generated project resolves every dependency from scratch on its
// first install, which fails outright when the user's package manager enforces a
// minimum release age (Yarn's npmMinimalAgeGate, pnpm's minimumReleaseAge, npm's
// min-release-age): the first-party packages are only minutes old at that point,
// and an exact pin leaves the resolver no older candidate to fall back to. A
// lockfile removes the resolution step entirely, so the gate never applies — and
// unlike loosening the pins or disabling the gate, it leaves the user's policy
// in force for every dependency they add later.
//
// Run this AFTER `nx build create-twenty-app` and AFTER the libraries are live on
// the registry, then publish; the publish workflow in twentyhq/twenty-infra runs
// it in that order.
import { execFileSync } from 'child_process';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { basename, dirname, join, resolve } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { TEMPLATE_FIRST_PARTY_PACKAGES } from '@/constants/template-packages';
import createTwentyAppPackageJson from 'package.json';
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
const REPO_ROOT = resolve(PACKAGE_ROOT, '..', '..');
const TEMPLATE_MANIFEST_PATH = join(
PACKAGE_ROOT,
'src/constants/template/package.json',
);
const DEFAULT_OUTPUT_PATH = join(
PACKAGE_ROOT,
'dist/constants/template/yarn.lock',
);
const PUBLIC_REGISTRY = 'https://registry.npmjs.org';
// Matches the monorepo's own npmMinimalAgeGate. Stated explicitly because nothing
// is inherited here: the lockfile is resolved in a temp directory outside the
// repo, with YARN_* stripped, so the effective gate would otherwise be zero.
const RELEASE_MINIMAL_AGE_GATE = '3d';
type Options = {
version: string;
registry: string;
outputPath: string;
};
const parseOptions = (argv: string[]): Options => {
const read = (name: string) => {
const prefix = `--${name}=`;
const flag = argv.find((argument) => argument.startsWith(prefix));
const value = flag?.slice(prefix.length);
// nx interpolates an unsupplied {args.foo} to an empty string.
return value === '' ? undefined : value;
};
return {
version: read('version') ?? createTwentyAppPackageJson.version,
registry: (read('registry') ?? PUBLIC_REGISTRY).replace(/\/$/, ''),
outputPath: read('out') ?? DEFAULT_OUTPUT_PATH,
};
};
// The generated project runs the Yarn its package.json pins. Generating the
// lockfile with a different Yarn can produce a lockfile that release pins reject
// with YN0028 on the user's first CI run, so refuse to guess.
const resolveYarnBinary = (templatePackageManager: string) => {
const yarnrc = fs.readFileSync(join(REPO_ROOT, '.yarnrc.yml'), 'utf8');
const yarnPath = /^yarnPath:\s*(.+)$/m.exec(yarnrc)?.[1]?.trim();
if (!isDefined(yarnPath)) {
throw new Error('No yarnPath found in the monorepo .yarnrc.yml');
}
const repoYarnVersion = /yarn-(.+)\.cjs$/.exec(basename(yarnPath))?.[1];
const templateYarnVersion = templatePackageManager.replace(/^yarn@/, '');
if (repoYarnVersion !== templateYarnVersion) {
throw new Error(
`The scaffold template pins yarn@${templateYarnVersion} but the monorepo runs yarn ${repoYarnVersion}. ` +
'Align them before generating the template lockfile.',
);
}
return join(REPO_ROOT, yarnPath);
};
const buildTemplateManifest = (version: string) => {
const manifest = fs.readJsonSync(TEMPLATE_MANIFEST_PATH);
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
manifest.devDependencies[packageName] = version;
}
return manifest;
};
const buildYarnrc = ({
registry,
version,
}: {
registry: string;
version: string;
}) => {
const { protocol, hostname } = new URL(registry);
return [
'nodeLinker: node-modules',
'enableTelemetry: false',
'enableScripts: false',
`npmRegistryServer: "${registry}"`,
// Yarn refuses plain HTTP unless the host is whitelisted by name, so take it
// from the registry rather than assuming localhost.
...(protocol === 'http:'
? ['unsafeHttpWhitelist:', ` - ${hostname}`]
: []),
// Scaffolded projects install this lockfile without re-resolving, which is
// exactly what lets them satisfy a consumer's age gate -- and equally what
// stops that gate from ever inspecting these versions. This resolution is
// therefore the only point at which an age gate applies to the tree a
// generated project receives, so hold one here, and waive it for nothing
// beyond the exact first-party versions this release is publishing.
`npmMinimalAgeGate: ${RELEASE_MINIMAL_AGE_GATE}`,
'npmPreapprovedPackages:',
...TEMPLATE_FIRST_PARTY_PACKAGES.map((name) => ` - "${name}@${version}"`),
'',
].join('\n');
};
const assertResolvedWithIntegrity = ({
lockfile,
packageName,
version,
}: {
lockfile: string;
packageName: string;
version: string;
}) => {
// Entry keys merge when several descriptors share one resolution
// ("pkg@npm:1.0.0, pkg@npm:^1.0.0":), so match the resolution line instead.
const resolution = `resolution: "${packageName}@npm:${version}"`;
const resolutionIndex = lockfile.indexOf(resolution);
if (resolutionIndex === -1) {
throw new Error(
`${packageName}@${version} is missing from the generated lockfile. Is it published yet?`,
);
}
const entryEnd = lockfile.indexOf('\n\n', resolutionIndex);
const entry = lockfile.slice(
resolutionIndex,
entryEnd === -1 ? undefined : entryEnd,
);
if (!entry.includes('checksum:')) {
throw new Error(
`${packageName}@${version} resolved without an integrity checksum.`,
);
}
};
// A registry serving tarballs from non-conventional URLs makes Yarn pin each
// entry to that exact host via __archiveUrl, which would not resolve for anyone
// else. Only the public registry produces a lockfile we can ship.
const assertNoRegistryPinning = ({
lockfile,
registry,
}: {
lockfile: string;
registry: string;
}) => {
if (registry !== PUBLIC_REGISTRY) {
// The release workflow always regenerates against the public registry right
// before publishing, so a lockfile built here is only ever local scaffolding.
// Say so out loud rather than passing silently.
console.warn(
`Generated against ${registry}, not ${PUBLIC_REGISTRY}: this lockfile is for local testing and must not be published.`,
);
return;
}
if (lockfile.includes('__archiveUrl')) {
throw new Error(
'The generated lockfile pins entries to a specific registry host. Regenerate it against the public registry.',
);
}
};
const generateTemplateLock = async ({
version,
registry,
outputPath,
}: Options) => {
const manifest = buildTemplateManifest(version);
const yarnBinary = resolveYarnBinary(manifest.packageManager);
const workingDirectory = await fs.mkdtemp(
join(tmpdir(), 'twenty-template-lock-'),
);
try {
await fs.writeJson(join(workingDirectory, 'package.json'), manifest, {
spaces: 2,
});
await fs.writeFile(
join(workingDirectory, '.yarnrc.yml'),
buildYarnrc({ registry, version }),
);
// YARN_* variables outrank the .yarnrc.yml written above, so a registry or
// gate exported by the surrounding CI job would silently change the result.
const environment = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith('YARN_')),
);
execFileSync(
process.execPath,
[yarnBinary, 'install', '--mode=update-lockfile'],
{ cwd: workingDirectory, stdio: 'inherit', env: environment },
);
const lockfile = await fs.readFile(
join(workingDirectory, 'yarn.lock'),
'utf8',
);
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
assertResolvedWithIntegrity({ lockfile, packageName, version });
}
assertNoRegistryPinning({ lockfile, registry });
await fs.ensureDir(dirname(outputPath));
await fs.writeFile(outputPath, lockfile);
const resolutionCount = lockfile.match(/^ {2}resolution:/gm)?.length ?? 0;
console.log(
`Wrote ${outputPath} (${resolutionCount} resolutions, ${TEMPLATE_FIRST_PARTY_PACKAGES.join(', ')} @ ${version})`,
);
} finally {
await fs.remove(workingDirectory);
}
};
generateTemplateLock(parseOptions(process.argv.slice(2))).catch(
(error: unknown) => {
console.error(
`Failed to generate the template lockfile: ${error instanceof Error ? error.message : error}`,
);
process.exit(1);
},
);

View File

@@ -0,0 +1,289 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import createTwentyAppPackageJson from 'package.json';
import { join } from 'path';
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
...actual,
copy: jest.fn().mockResolvedValue(undefined),
};
});
const UNIVERSAL_IDENTIFIERS_PATH = join(
'src',
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
`;
// Template package.json matching template/package.json
const TEMPLATE_PACKAGE_JSON = {
name: 'template-app',
version: '0.1.0',
license: 'MIT',
scripts: { twenty: 'twenty' },
dependencies: {},
devDependencies: {
'twenty-client-sdk': '0.0.0',
'twenty-sdk': '0.0.0',
'twenty-ui': '0.0.0',
},
};
describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fs.ensureDir(testAppDirectory);
// Seed the files that generateUniversalIdentifiers and updatePackageJson
// expect to find (since fs.copy is mocked and won't actually create them)
await fs.ensureDir(join(testAppDirectory, 'src', 'constants'));
await fs.writeFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(testAppDirectory, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
jest.clearAllMocks();
});
afterEach(async () => {
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
it('should call fs.copy to copy base application template', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Two fs.copy calls: (1) the template directory, (2) AGENTS.md → CLAUDE.md
expect(fs.copy).toHaveBeenCalledTimes(2);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('template'),
testAppDirectory,
);
expect(fs.copy).toHaveBeenCalledWith(
join(testAppDirectory, 'AGENTS.md'),
join(testAppDirectory, 'CLAUDE.md'),
);
});
it('should replace placeholders in universal-identifiers.ts with real values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
expect(content).toContain("APP_DISPLAY_NAME = 'My Test App'");
expect(content).toContain("APP_DESCRIPTION = 'A test application'");
expect(content).not.toContain('DISPLAY-NAME-TO-BE-GENERATED');
expect(content).not.toContain('DESCRIPTION-TO-BE-GENERATED');
expect(content).not.toContain('UUID-TO-BE-GENERATED');
// Both UUIDs should be valid v4 format
const uuidMatches = content.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
);
expect(uuidMatches).toHaveLength(2);
});
it('should generate different UUIDs for each identifier', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const uuidMatches = content.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
);
expect(uuidMatches).toHaveLength(2);
expect(uuidMatches![0]).not.toBe(uuidMatches![1]);
});
it('should update package.json with app name and matching package versions', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-ui']).toBe(
createTwentyAppPackageJson.version,
);
});
it('should create an empty public directory in the scaffolded project', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const publicDirectoryPath = join(testAppDirectory, 'public');
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
expect(publicDirectoryStats.isDirectory()).toBe(true);
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: '',
appDirectory: testAppDirectory,
});
const content = await fs.readFile(
join(testAppDirectory, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
expect(content).toContain("APP_DESCRIPTION = ''");
});
it.each(['CHANGELOG.md', 'SETUP.md', 'locales/en.json'])(
'should seed %s in the base template',
async (seedFileName) => {
const templateDirectory = join(
__dirname,
'..',
'..',
'constants',
'template',
);
expect(await fs.pathExists(join(templateDirectory, seedFileName))).toBe(
true,
);
},
);
it('should generate unique UUIDs across different scaffolds', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(join(firstAppDir, 'src', 'constants'));
await fs.writeFile(
join(firstAppDir, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(firstAppDir, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(join(secondAppDir, 'src', 'constants'));
await fs.writeFile(
join(secondAppDir, UNIVERSAL_IDENTIFIERS_PATH),
TEMPLATE_UNIVERSAL_IDENTIFIERS,
);
await fs.writeJson(
join(secondAppDir, 'package.json'),
TEMPLATE_PACKAGE_JSON,
);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
const firstConstants = await fs.readFile(
join(firstAppDir, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const secondConstants = await fs.readFile(
join(secondAppDir, UNIVERSAL_IDENTIFIERS_PATH),
'utf8',
);
const uuidRegex =
/APPLICATION_UNIVERSAL_IDENTIFIER = '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstConstants.match(uuidRegex)?.[1];
const secondUuid = secondConstants.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
});

View File

@@ -0,0 +1,10 @@
import { convertToLabel } from '@/utils/convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});

View File

@@ -0,0 +1,211 @@
import { parse } from 'yaml';
import { install } from '@/utils/install';
type ExecCallback = (error: Error | null, stdout?: string) => void;
jest.mock('child_process', () => ({ exec: jest.fn() }));
// node's `exec` is overloaded and its widest overload does not accept this
// callback shape, so take the mock from the module registry under the shape the
// promisified call actually uses, rather than casting the imported binding.
const { exec: mockExec } = jest.requireMock<{
exec: jest.Mock<void, [string, unknown, ExecCallback]>;
}>('child_process');
type CommandOutcome = { stdout?: string; stderr?: string; fails?: boolean };
const mockCommands = (outcomes: Record<string, CommandOutcome>) => {
mockExec.mockImplementation(
(
command: string,
_options: unknown,
callback: (error: Error | null, stdout?: string) => void,
) => {
const outcome =
Object.entries(outcomes).find(([prefix]) =>
command.startsWith(prefix),
)?.[1] ?? {};
if (outcome.fails !== true) {
callback(null, outcome.stdout ?? '');
return;
}
const error = Object.assign(new Error(`Command failed: ${command}`), {
stdout: outcome.stdout ?? '',
stderr: outcome.stderr ?? '',
});
callback(error);
},
);
};
const quarantineOutput = ({
descriptor,
selector,
}: {
descriptor: string;
selector: string;
}) =>
`➤ YN0016: │ ${descriptor}@npm:${selector}: All versions satisfying "${selector}" are quarantined`;
const APP_DIRECTORY = '/tmp/some-scaffolded-app';
// The remediation is YAML the reader pastes into .yarnrc.yml, so assert it parses
// rather than that it reads correctly: a scoped descriptor left unquoted looks
// right in a substring check and is a YAML error in the file.
const parseSuggestedYarnrc = (message: string) => {
const lines = message.split('\n');
const blockStart = lines.findIndex(
(line) => line.trim() === 'npmPreapprovedPackages:',
);
if (blockStart === -1) {
throw new Error(`No npmPreapprovedPackages block in message:\n${message}`);
}
const block: string[] = [];
for (const line of lines.slice(blockStart)) {
if (line.trim().length === 0) {
break;
}
block.push(line.slice(' '.length));
}
const parsed: { npmPreapprovedPackages: string[] } = parse(block.join('\n'));
return parsed;
};
const installAndCatch = () =>
install(APP_DIRECTORY).then(
() => '',
(error: Error) => error.message,
);
describe('install', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('installs without immutable mode so the template lockfile can be finalised', async () => {
mockCommands({});
await install(APP_DIRECTORY);
expect(mockExec).toHaveBeenCalledWith(
'yarn install --no-immutable',
{ cwd: APP_DIRECTORY },
expect.any(Function),
);
});
it('reports the failure instead of leaving a project without dependencies', async () => {
mockCommands({
'yarn install': { fails: true, stdout: 'YN0001: Something exploded' },
});
await expect(install(APP_DIRECTORY)).rejects.toThrow(
/Dependency installation failed/,
);
});
it('says how to recover, since the scaffolded project itself is intact', async () => {
mockCommands({
'yarn install': { fails: true, stdout: 'YN0001: Something exploded' },
});
const message = await installAndCatch();
expect(message).toContain('YN0001: Something exploded');
expect(message).toContain('run `yarn install` there');
});
it('names the packages the gate actually quarantined', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: quarantineOutput({
descriptor: 'twenty-ui',
selector: '2.41.0',
}),
},
});
const message = await installAndCatch();
expect(message).toContain('minimum');
expect(message).toContain(APP_DIRECTORY);
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['twenty-ui@2.41.0'],
});
// Never advise lowering the gate itself.
expect(message).not.toContain('npmMinimalAgeGate');
});
it('waives the third-party package Yarn named, not the first-party ones', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: quarantineOutput({
descriptor: '@scope/some-tool',
selector: '^1.2.3',
}),
},
});
const message = await installAndCatch();
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['@scope/some-tool@^1.2.3'],
});
expect(message).not.toContain('twenty-ui');
expect(message).not.toContain('twenty-sdk');
});
it('waives a quarantined dist-tag by name, since a tag is not a usable range', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout:
'➤ YN0016: │ twenty-ui@npm:latest: The version for tag "latest" is quarantined, and no lower version is available',
},
});
const message = await installAndCatch();
expect(parseSuggestedYarnrc(message)).toEqual({
npmPreapprovedPackages: ['twenty-ui'],
});
expect(message).not.toContain('twenty-ui@latest');
});
it('falls back to the yarn output when no package can be identified', async () => {
mockCommands({
'yarn install': {
fails: true,
stdout: '➤ YN0016: │ something unparseable happened',
},
});
const message = await installAndCatch();
expect(message).toContain('something unparseable happened');
expect(message).not.toContain('npmPreapprovedPackages');
});
it('tolerates corepack being unavailable when yarn still installs', async () => {
mockCommands({ 'corepack enable': { fails: true, stderr: 'no corepack' } });
await expect(install(APP_DIRECTORY)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,139 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
import { TEMPLATE_FIRST_PARTY_PACKAGES } from '@/constants/template-packages';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
onProgress?: (message: string) => void;
}) => {
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
await mirrorAgentsToClaude({ appDirectory });
await addEmptyPublicDirectory({ appDirectory });
onProgress?.('Generating unique application identifiers');
await generateUniversalIdentifiers({
appDisplayName,
appDescription,
appDirectory,
});
onProgress?.('Updating package.json');
await updatePackageJson({ appName, appDirectory });
};
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
// so we store them without the leading dot and rename after copying.
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
const sourcePath = join(appDirectory, from);
if (await fs.pathExists(sourcePath)) {
await fs.rename(sourcePath, join(appDirectory, to));
}
}
};
// AGENTS.md is the cross-tool standard; Claude Code prefers CLAUDE.md and only
// falls back to AGENTS.md, so we mirror the file to keep a single source of truth.
const mirrorAgentsToClaude = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.copy(
join(appDirectory, 'AGENTS.md'),
join(appDirectory, 'CLAUDE.md'),
);
};
const addEmptyPublicDirectory = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.ensureDir(join(appDirectory, 'public'));
};
const generateUniversalIdentifiers = async ({
appDisplayName,
appDescription,
appDirectory,
}: {
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
const universalIdentifiersPath = join(
appDirectory,
SRC_FOLDER,
'constants',
'universal-identifiers.ts',
);
const universalIdentifiersFileContent = await fs.readFile(
universalIdentifiersPath,
'utf-8',
);
await fs.writeFile(
universalIdentifiersPath,
universalIdentifiersFileContent
.replace('DISPLAY-NAME-TO-BE-GENERATED', appDisplayName)
.replace('DESCRIPTION-TO-BE-GENERATED', appDescription)
.replace(/UUID-TO-BE-GENERATED/g, () => v4()),
);
};
const updatePackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const packageJson = await fs.readJson(join(appDirectory, 'package.json'));
// The template yarn.lock keeps its placeholder workspace name: the only entry
// naming the project is the workspace root, which the scaffolder's `yarn install`
// rewrites from package.json without re-resolving a single dependency. Renaming
// it here would move the entry out of sort order and break `--immutable`.
packageJson.name = appName;
for (const packageName of TEMPLATE_FIRST_PARTY_PACKAGES) {
packageJson.devDependencies[packageName] =
createTwentyAppPackageJson.version;
}
await fs.writeFile(
join(appDirectory, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf8',
);
};

View File

@@ -0,0 +1,6 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};

View File

@@ -0,0 +1,33 @@
import { execSync } from 'node:child_process';
const DOCKER_DOWNLOAD_URLS: Record<string, string> = {
darwin: 'https://docs.docker.com/desktop/setup/install/mac-install/',
win32: 'https://docs.docker.com/desktop/setup/install/windows-install/',
linux: 'https://docs.docker.com/engine/install/',
};
export const isDockerInstalled = (): boolean => {
try {
execSync('docker --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
export const getDockerInstallInstructions = (): string => {
const url =
DOCKER_DOWNLOAD_URLS[process.platform] ?? DOCKER_DOWNLOAD_URLS.linux;
return [
' Docker is required but not installed.',
'',
` Install Docker: ${url}`,
'',
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
].join('\n');
};

View File

@@ -0,0 +1,110 @@
import chalk from 'chalk';
import { exec } from 'child_process';
import { promisify } from 'util';
const execPromise = promisify(exec);
// Raised as "All versions satisfying <range> are quarantined", or for a tag as
// "The version for tag <tag> is quarantined", when every candidate version is
// younger than the configured npmMinimalAgeGate.
const YARN_QUARANTINE_ERROR_CODE = 'YN0016';
const QUARANTINED_PACKAGE_PATTERN =
/(@?[\w.-]+(?:\/[\w.-]+)?)@npm:(\S+?): (?:All versions satisfying|The version for tag)/g;
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
// A selector Yarn can test against a version works as a preapproval entry; a
// dist-tag does not, and only the bare package name will waive it.
const VERSION_SELECTOR_PATTERN = /^[\^~>=<\s]*\d/;
const OUTPUT_TAIL_LINES = 20;
const tail = (output: string) =>
output.trim().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
const parseQuarantinedPackages = (output: string) => {
const matches = output
.replace(ANSI_ESCAPE_PATTERN, '')
.matchAll(QUARANTINED_PACKAGE_PATTERN);
return [
...new Set(
[...matches].map(([, packageName, selector]) =>
VERSION_SELECTOR_PATTERN.test(selector)
? `${packageName}@${selector}`
: packageName,
),
),
];
};
const buildQuarantineMessage = ({
appDirectory,
quarantinedPackages,
}: {
appDirectory: string;
quarantinedPackages: string[];
}) =>
[
'Dependency installation failed: your package manager enforces a minimum',
'release age, and these packages were published too recently to satisfy it:',
'',
...quarantinedPackages.map((descriptor) => ` ${descriptor}`),
'',
`Your project was created at ${appDirectory} and does not need to be scaffolded again.`,
'You can either wait until those packages are older than your configured gate',
'and run `yarn install` there, or waive the gate for just them by adding to',
"that project's .yarnrc.yml:",
'',
' npmPreapprovedPackages:',
// Quoted because a scoped descriptor starts with "@", which YAML reserves:
// an unquoted `- @scope/pkg@^1.2.3` is a parse error, not a value.
...quarantinedPackages.map((descriptor) => ` - "${descriptor}"`),
'',
'That leaves the gate in force for every other dependency.',
].join('\n');
export const install = async (
root: string,
onProgress?: (message: string) => void,
) => {
onProgress?.('Enabling corepack');
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('corepack enable failed:'), error.stderr);
}
onProgress?.('Running yarn install');
try {
// Yarn installs immutably by default when CI is set, but this install is what
// finalises the template's lockfile: it rewrites the workspace root entry to
// the project's real name. Immutable mode rejects that with YN0028.
await execPromise('yarn install --no-immutable', { cwd: root });
} catch (error: any) {
const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}`;
const quarantinedPackages = output.includes(YARN_QUARANTINE_ERROR_CODE)
? parseQuarantinedPackages(output)
: [];
// Continuing here would authenticate, sync and report success over a project
// that has no node_modules, so surface the failure instead.
throw new Error(
quarantinedPackages.length > 0
? buildQuarantineMessage({
appDirectory: root,
quarantinedPackages,
})
: [
`Dependency installation failed in ${root}:`,
'',
tail(output),
'',
// The project itself is intact: package.json carries its real name and
// the lockfile is the shipped one, which a plain install reconciles.
'The project was created. Fix the problem above and run `yarn install` there.',
].join('\n'),
);
}
};

View File

@@ -0,0 +1,70 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { promisify } from 'util';
import { exec } from 'child_process';
const execPromise = promisify(exec);
const isInGitRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('git rev-parse --is-inside-work-tree', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isInMercurialRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('hg --cwd . root', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isDefaultBranchSet = async (root: string): Promise<boolean> => {
try {
await execPromise('git config init.defaultBranch', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
export const tryGitInit = async (root: string): Promise<boolean> => {
try {
await execPromise('git --version', { cwd: root });
if (
(await isInGitRepository(root)) ||
(await isInMercurialRepository(root))
) {
return false;
}
await execPromise('git init', { cwd: root });
try {
if (!(await isDefaultBranchSet(root))) {
await execPromise('git checkout -b main', { cwd: root });
}
await execPromise('git add -A', { cwd: root });
await execPromise(
'git commit -m "Initial commit from Create Twenty App"',
{
cwd: root,
},
);
return true;
} catch {
fs.rm(join(root, '.git'), { recursive: true, force: true });
return false;
}
} catch {
return false;
}
};

View File

@@ -0,0 +1,33 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"],
"package.json": ["./package.json"]
},
"jsx": "react"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs"
],
"exclude": [
"src/constants/template/vitest.config.ts",
"src/constants/template/vitest.unit.config.ts",
"src/constants/template/src/**",
"src/constants/template/tsconfig.spec.json"
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**",
"src/constants/template/src/**",
"src/constants/template/vitest.config.ts",
"src/constants/template/vitest.unit.config.ts",
"src/constants/template/tsconfig.spec.json"
]
}

View File

@@ -0,0 +1,106 @@
import fs from 'fs-extra';
import path from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import packageJson from './package.json';
import type { PackageJson } from 'type-fest';
const moduleEntries = Object.keys((packageJson as any).exports || {})
.filter(
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
)
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
const entries = ['src/cli.ts', ...moduleEntries];
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
const copyAssetPlugin = (targets: { src: string; dest: string }[]) => {
return {
name: 'copy-assets',
closeBundle: async () => {
for (const target of targets) {
await fs.copy(
path.resolve(__dirname, target.src),
path.resolve(__dirname, target.dest),
);
}
},
};
};
export default defineConfig(() => {
const tsConfigPath = path.resolve(__dirname, './tsconfig.lib.json');
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/create-twenty-app',
resolve: {
tsconfigPaths: true,
},
plugins: [
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
copyAssetPlugin([
{
src: 'src/constants/template',
dest: 'dist/constants/template',
},
]),
],
build: {
outDir: 'dist',
lib: { entry: entries, name: 'create-twenty-app' },
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = ['path', 'fs', 'child_process', 'util'];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});

View File

@@ -0,0 +1,50 @@
{
"name": "twenty",
"version": "0.1.0",
"description": "Create, operate, develop, publish, and query Twenty apps through Codex.",
"author": {
"name": "Twenty PBC",
"url": "https://twenty.com/"
},
"homepage": "https://docs.twenty.com/",
"repository": "https://github.com/twentyhq/twenty",
"license": "AGPL-3.0",
"keywords": [
"twenty",
"create-twenty-app",
"mcp",
"oauth",
"crm",
"apps",
"scaffolding",
"cli",
"twenty-sdk",
"publishing"
],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Twenty",
"shortDescription": "Create, develop, publish, and query apps",
"longDescription": "Scaffold, develop, deploy, and publish Twenty apps end-to-end. Five focused skills cover app creation, entity development (objects, fields, logic, layouts, front components), remote/sync/deploy operations, marketplace publishing assets, and workspace data retrieval via MCP. The bundled Twenty docs MCP server keeps guidance current, and the setup helper wires per-user workspace MCP with OAuth in one command.",
"developerName": "Twenty PBC",
"category": "Coding",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://twenty.com/",
"privacyPolicyURL": "https://twenty.com/privacy-policy",
"termsOfServiceURL": "https://twenty.com/terms",
"defaultPrompt": [
"Create and run a new Twenty app",
"Push a Twenty app to production",
"Develop a Twenty app feature"
],
"brandColor": "#000000",
"composerIcon": "./assets/twenty-logo.svg",
"logo": "./assets/twenty-logo.png",
"screenshots": []
}
}

View File

@@ -0,0 +1,2 @@
.mcp.local.json
dist/

View File

@@ -0,0 +1,7 @@
{
"mcpServers": {
"twenty-docs": {
"url": "https://docs.twenty.com/mcp"
}
}
}

View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,66 @@
# Twenty Codex Plugin — Agent Guidance
This file is the canonical entry point for any agent (Codex, ChatGPT Developer Mode, or compatible) running with the Twenty plugin loaded. It encodes the boundaries, conventions, and operating rules that apply across all five bundled skills.
Per-skill SKILL.md files own task-specific guidance. This file owns the cross-skill expectations.
## What This Plugin Does
The Twenty Codex plugin helps users build, operate, develop, publish, and query Twenty apps — modular extensions for the Twenty CRM platform. It bundles:
- **5 skills** for the canonical Twenty app workflows.
- **15 reference docs** under `references/` covering concepts, data model, UI, layouts, CLI, publishing, and MCP.
- **1 public MCP server** (`twenty-docs`) for searching official Twenty documentation.
- **1 setup helper** (`scripts/setup-mcp.sh`) for adding user-local workspace MCP endpoints.
A Twenty app is a standalone npm package that extends a running Twenty instance. It is not a standalone application. For the full mental model read `references/concepts/how-apps-work.md` before doing anything substantive.
## Skill Routing
Pick exactly one skill at a time based on the user's intent. Skills are deliberately disjoint.
| User intent | Skill |
| -------------------------------------------------------------------------- | ---------------- |
| Scaffold a brand-new Twenty app | `create-app` |
| Add or modify objects, fields, logic, views, front components, workflows | `develop-app` |
| Manage remotes, sync, build, deploy, logs, troubleshoot, CI/CD | `manage-app` |
| Prepare README, marketplace metadata, logos, screenshots for publishing | `publish-app` |
| Connect to a workspace via MCP, retrieve records, format readable Markdown | `use-twenty-mcp` |
If the user's request straddles two skills, do the boundary task in the first skill and explicitly hand off to the second. Never silently combine.
## Durable Operating Rules
The cross-skill rules live in `references/concepts/operating-rules.md`. That file is the single source of truth: the build includes it in the shared distribution and the individual skill bundles, so all installations use the same authored rules. Read it before any non-trivial task, and do not restate or fork it here.
Codex-specific additions on top of those rules:
- Workspace MCP endpoints belong in the user's local `.mcp.json` and Codex MCP config. This plugin only ships the public `twenty-docs` MCP server. Use `scripts/setup-mcp.sh` to configure a workspace MCP endpoint for a specific user.
## Reference Doc Map
When a skill points at a reference, read only what the task needs:
- `concepts/how-apps-work.md` — foundational. Read at the start of any non-trivial task.
- `concepts/operating-rules.md` — the durable cross-skill rules. Authoritative; nothing restates them.
- `develop-app/app-structure.md` — file layout, entity creation, validation checklist.
- `develop-app/data-model.md` — objects, fields, relations, roles, permissions.
- `develop-app/front-components.md` — front component source, SDK imports, runtime verification.
- `develop-app/layout.md` — views, navigation, page layouts, front component placement.
- `develop-app/standalone-pages.md` — full-page custom UI through standalone page layouts.
- `develop-app/logic.md` — logic functions, skills, agents, post-install, connection providers.
- `develop-app/workflows.md` — workflows, manual triggers, draft/activate lifecycle.
- `develop-app/tests.md` — test organization (`*.spec.ts` in `__tests__/`).
- `design/front-component-ui.md` — Twenty UI defaults and visual design rules.
- `manage-app/cli-and-sync.md` — CLI command semantics, sync modes, build, deploy, logs, CI/CD.
- `publish-app/prepare-for-app-store.md` — README, marketplace metadata, logos, screenshots.
- `use-twenty-mcp/setup.md` — workspace MCP URL normalization and OAuth setup.
- `use-twenty-mcp/result-formatting.md` — record link building, date formatting, readable Markdown.
## How to Verify You're Following Best Practices
When editing the source package, run `npx nx run twenty-agent-skills:validate` and `npx nx run twenty-agent-skills:build` after changes. Every box in `CHECKLIST.md` must be satisfied before a release.
## Boundaries
This file is for _agents using the plugin_. If you are _editing the plugin itself_, see [`CONTRIBUTING.md`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-agent-skills/CONTRIBUTING.md).

View File

@@ -0,0 +1,81 @@
# Changelog
All notable changes to Twenty Agent Skills and its Codex plugin are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Entries reference the canonical skills (`create-app`, `develop-app`, `manage-app`, `publish-app`, `use-twenty-mcp`) and the validation script (`scripts/validate-source.js`).
## [Unreleased]
### Changed
- Consolidated canonical skills, shared references, and Codex metadata into `packages/twenty-agent-skills`, replacing the separate source and generated packages. The Codex plugin identifier remains `twenty`.
- Build one distribution in ignored `dist/` for both the `skills` CLI and Codex. Individual skills contain their required references; only the publishing branch contains generated copies.
- Publish the built distribution at the root of the `agent-skills` branch from `main`. Installation instructions use that branch and explain local installation before the first publish.
- Unified source validation, distribution validation, installation checks, and CI under `twenty-agent-skills`.
## [0.1.0] - Unreleased
The first version of the Twenty Codex plugin.
### Added
- `references/concepts/operating-rules.md`: the durable cross-skill rules, harness-neutral and authoritative. `AGENTS.md` and the four app skills link to it instead of restating it, and `assertOperatingRulesSingleSource` fails validation when any of them forks a rule.
- `AGENTS.md` at the plugin root: durable cross-skill operating rules, skill routing table, and reference-doc map.
- `CHANGELOG.md` (this file).
- `CHECKLIST.md`: best-practices compliance matrix mapping each official Codex plugin requirement to an automated check or a manual sign-off.
- `CONTRIBUTING.md`: maintainer guide for adding skills, adding references, bumping versions, updating screenshots, and running validation.
- `project.json`: Nx targets (`validate`, `test`, `setup:mcp`, `fmt`).
- `templates/marketplace.example.json`: copy-pasteable template for the user-local `.agents/plugins/marketplace.json` entry.
- `assets/screenshots/README.md`: capture brief for the three marketplace screenshots.
- `## When To Use` section in every SKILL.md with representative trigger phrases and explicit "do not use this skill for X" boundary callouts.
- New validators under `scripts/validators/`: `assertInterfaceFields`, `assertAssets`, `assertMarketplaceTemplate`, `assertSkillTriggerPhrases`.
- `scripts/__tests__/validate.spec.js`: 29 `node:test` cases (smoke + targeted negative cases) covering every validator.
- `.github/workflows/ci-codex-plugin.yaml`: CI workflow running `validate` and `test` on PRs touching the plugin.
### Changed
- `scripts/validate.js` refactored from a single 760-line file into a thin entry point plus focused modules under `scripts/validators/` (`lib`, `metadata`, `assets`, `skills`, `references`, `cross-doc-contracts`, `setup-helper`).
- `package.json`: exposed `test` script; added `AGENTS.md`, `CHANGELOG.md`, `CHECKLIST.md`, `CONTRIBUTING.md`, `templates` to `files`.
- `.codex-plugin/plugin.json`: rewrote `interface.longDescription` into 3 scannable sentences (was a single ~400-char sentence).
- `references/use-twenty-mcp/setup.md`: rewritten as multi-harness MCP setup (Codex, Claude Code, Cursor, generic clients) with self-hosted and localhost workspaces as first-class targets.
- `skills/use-twenty-mcp/SKILL.md`, `skills/publish-app/SKILL.md`: harness-neutral wording; the public `twenty-docs` MCP server is an optional enhancement rather than an assumed dependency.
- `README.md`: restructured into What/Installation/Skills/MCP/Development sections; added skills-overview table; linked `CONTRIBUTING.md`, `CHECKLIST.md`, `CHANGELOG.md`.
- `scripts/validators/lib.js` `isAllowedDocumentationHost`: added `developers.openai.com`, `keepachangelog.com`, `semver.org` to the placeholder host allowlist so external documentation references in `CHECKLIST.md`, `CONTRIBUTING.md`, and `CHANGELOG.md` pass validation.
- `references/develop-app/tests.md` and `references/manage-app/cli-and-sync.md`: documented that integration tests must run against the isolated test instance (`yarn twenty docker:start --test`, port `2021`) instead of the dev instance, since the test harness installs and uninstalls the app on its target server.
- `skills/manage-app/SKILL.md`: added direct test-run routing so "run tests" requests load the tests reference and run full suites with `TWENTY_API_URL=http://localhost:2021`.
- `scripts/validators/cross-doc-contracts.js`: added a testing-guidance contract so validation fails if the port-2021 integration-test rule drifts out of the manage skill or references.
- `references/develop-app/app-structure.md`, `skills/develop-app/SKILL.md`, `references/develop-app/logic.md`, `references/develop-app/front-components.md`: made the one-export-per-file rule explicit — every helper, type, and client file exports exactly one thing, and multiple function exports in a single file are forbidden. The rule counts exports, not declarations: a local, non-exported type may live alongside the util, but an exported or reused type splits into `src/types/<name>.ts` separate from `src/utils/<name>.util.ts`.
### Plugin Structure
- `.codex-plugin/plugin.json` manifest with `interface` metadata (display name, descriptions, category, capabilities, branding, default prompts).
- `package.json` declaring the workspace package and listing shipped files.
- `.mcp.json` declaring only the public `twenty-docs` MCP server at `https://docs.twenty.com/mcp`.
- `assets/twenty-logo.png` and `assets/twenty-logo.svg` for marketplace branding.
### Skills (`skills/`)
- `create-app` — scaffold a new Twenty app via `create-twenty-app`, with prompts for the Twenty instance URL and Docker vs existing-instance choice.
- `develop-app` — add or modify Twenty app entities (objects, fields, logic functions, roles, views, layouts, skills, agents, connection providers, front components); enforces `yarn twenty dev:add` for entity generation and one-shot `yarn twenty dev --once` sync.
- `manage-app` — remotes, sync, build, deploy, logs, function execution, uninstall, and CI/CD for existing apps; requires explicit confirmation for production-affecting operations.
- `publish-app` — README/About copy, package metadata, `defineApplication` marketplace fields, logos, screenshots, npm/marketplace publication.
- `use-twenty-mcp` — workspace MCP connection, record retrieval, and readable Markdown output with linked record names.
- Per-skill `agents/openai.yaml` with `display_name`, `short_description` (≤64 chars), and `default_prompt` that mentions `$<skill-name>`.
### References (`references/`)
- `concepts/how-apps-work.md` — foundational SDK packages, remotes, sync lifecycle, front component rendering, app file structure, key concepts (linked from every skill).
- `develop-app/app-structure.md`, `data-model.md`, `front-components.md`, `layout.md`, `standalone-pages.md`, `logic.md`, `workflows.md`, `tests.md` — entity creation, file structure, validation checklist, full-page UI patterns, runtime verification.
- `design/front-component-ui.md` — visual design rules, Twenty UI defaults, design tokens, component selection.
- `manage-app/cli-and-sync.md` — CLI command semantics, sync modes, build, deploy, logs, CI/CD; explicit ban on watch mode (`yarn twenty dev` without `--once`) for agent use.
- `operations/command-execution.md` — external command execution patterns.
- `publish-app/prepare-for-app-store.md` — npm/marketplace publication checklist.
- `use-twenty-mcp/setup.md` — workspace MCP URL normalization and OAuth setup.
- `use-twenty-mcp/result-formatting.md` — record link building, date formatting, readable Markdown contract.
### Tooling
- `scripts/setup-mcp.sh` — helper to register a user-local workspace MCP endpoint with Codex; normalizes workspace URLs (adds `https://`, `/mcp` suffix), derives sensible MCP server names, supports localhost and custom domains, integrates with `codex mcp add` and OAuth.
- `scripts/validate.js` — single-file validation script enforcing version sync, no bundled workspace MCP, no secrets/non-placeholder URLs, canonical skill names, required SKILL.md + `agents/openai.yaml` shape, required reference files, cross-doc contracts (MCP formatting, front-component guidance, CLI guidance split, foundational concepts linkage), and `setup-mcp.sh` syntax + URL-normalization correctness.

View File

@@ -0,0 +1,113 @@
# Twenty Codex Plugin — Best Practices Compliance Checklist
This file is the authoritative compliance matrix. Every row maps an official requirement (Codex build guide, Codex best-practices guide, or OpenAI reference implementation) to either an **automated** check (a `scripts/validate-source.js` assertion function) or a **manual** sign-off.
A release is shippable when:
1. `npx nx run twenty-agent-skills:validate` and `npx nx run twenty-agent-skills:build` exit 0 (source and distribution checks pass).
2. Every `[manual]` row has a current sign-off date.
## Sources of Truth
- **Codex Plugin Build Guide** — https://developers.openai.com/codex/plugins/build
- **Codex Best Practices** — https://developers.openai.com/codex/learn/best-practices
- **Reference Implementation** — https://github.com/openai/codex-plugin-cc
## Plugin Manifest (`.codex-plugin/plugin.json`)
| # | Requirement | Source | Check |
| --- | ----------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------- |
| M1 | `name` present, kebab-case, lowercase | Build guide §Naming | [automated] `assertJsonMetadata` |
| M2 | `version` follows SemVer and matches `package.json` | Build guide §Manifest | [automated] `assertJsonMetadata` |
| M3 | `description` present and concise | Build guide §Required fields | [automated] `assertInterfaceFields` (planned) |
| M4 | `mcpServers` points at `./.mcp.json` | Build guide §MCP | [automated] `assertJsonMetadata` |
| M5 | `skills` points at `./skills/` | Build guide §Skills | [automated] `assertJsonMetadata` |
| M6 | `interface.displayName` present and non-empty | Build guide §Interface | [automated] `assertInterfaceFields` (planned) |
| M7 | `interface.shortDescription` ≤ 64 chars | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M8 | `interface.longDescription` present, multi-sentence | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M9 | `interface.category` matches marketplace enum (`Coding`) | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M10 | `interface.capabilities` non-empty subset of `Interactive,Read,Write` | Build guide §Capabilities | [automated] `assertInterfaceFields` (planned) |
| M11 | `interface.websiteURL`, `privacyPolicyURL`, `termsOfServiceURL` present | Build guide §Publisher links | [automated] `assertInterfaceFields` (planned) |
| M12 | `interface.brandColor` matches `#RRGGBB` and reflects Twenty brand | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned), [manual] brand alignment |
| M13 | `interface.defaultPrompt` is a non-empty array of strings | Build guide §Defaults | [automated] `assertInterfaceFields` (planned) |
| M14 | All manifest paths start with `./` and stay within plugin root | Build guide §Paths | [automated] `assertJsonMetadata` |
## Assets (`assets/`)
| # | Requirement | Source | Check |
| --- | ------------------------------------------------------------- | ------------------------ | ---------------------------------------------------- |
| A1 | `interface.logo` exists and is PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A2 | Logo PNG is ≥ 256×256 | Build guide §Assets | [automated] `assertAssets` (planned, PNG IHDR parse) |
| A3 | `interface.composerIcon` exists and is valid SVG or PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A4 | `interface.screenshots` has ≥ 1 entry | Build guide §Assets | [automated] `assertAssets` (planned) |
| A5 | Every screenshot path exists and is PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A6 | Screenshots show the plugin in action (not placeholder mocks) | Build guide §Marketplace | [manual] reviewer sign-off |
## Skills (`skills/<name>/SKILL.md`)
| # | Requirement | Source | Check |
| --- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------- |
| S1 | Canonical five skills present | Internal convention | [automated] `assertSkills` |
| S2 | No legacy skill names anywhere | Internal convention | [automated] `assertNoLegacySkillReferences` |
| S3 | Each `SKILL.md` has YAML frontmatter with `name` + `description` only | Build guide §Skills | [automated] `assertSkills` |
| S4 | Frontmatter `name` matches directory | Build guide §Skills | [automated] `assertSkills` |
| S5 | Each skill has `agents/openai.yaml` with `display_name`, `short_description` (≤64), `default_prompt` (mentions `$<skill>`) | OpenAI Agent format | [automated] `assertSkills` |
| S6 | Each skill is scoped to a single job with clear boundaries | Best practices §Skill scope | [manual] cross-skill audit |
| S7 | Each skill includes a `## When to use` trigger-phrase section | Best practices §Descriptions | [automated] `assertSkillTriggerPhrases` (planned) |
| S8 | Descriptions are disjoint across skills (no routing collision) | Best practices §Descriptions | [manual] cross-skill audit |
## References (`references/`)
| # | Requirement | Source | Check |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ----------------------------------------------- |
| R1 | All required reference files exist | Internal convention | [automated] `assertReferences` |
| R2 | `use-twenty-mcp` formatting contract intact across SKILL.md and result-formatting.md | Internal convention | [automated] `assertTwentyMcpFormattingContract` |
| R3 | Front component guidance correctly split across develop-app skill, layout.md, front-components.md, standalone-pages.md, app-structure.md, front-component-ui.md | Internal convention | [automated] `assertFrontComponentGuidance` |
| R4 | CLI guidance correctly split between develop-app and manage-app | Internal convention | [automated] `assertCliGuidanceSplit` |
| R5 | `concepts/how-apps-work.md` is foundational and linked from every skill | Internal convention | [automated] `assertHowAppsWork` |
## MCP (`.mcp.json` and `scripts/setup-mcp.sh`)
| # | Requirement | Source | Check |
| ---- | --------------------------------------------------------- | --------------------- | -------------------------------------- |
| MCP1 | `.mcp.json` declares only the public `twenty-docs` server | Internal convention | [automated] `assertJsonMetadata` |
| MCP2 | No workspace-specific MCP configs bundled anywhere | Internal convention | [automated] `assertNoBundledMcpConfig` |
| MCP3 | No bearer tokens or API keys anywhere in the package | Build guide §Security | [automated] `assertNoBundledMcpConfig` |
| MCP4 | No non-placeholder URLs in any file | Internal convention | [automated] `assertNoBundledMcpConfig` |
| MCP5 | `scripts/setup-mcp.sh` passes `bash -n` syntax check | Internal convention | [automated] `assertSetupHelper` |
| MCP6 | `setup-mcp.sh` correctly normalizes representative URLs | Internal convention | [automated] `assertSetupHelper` |
## Marketplace & Distribution
| # | Requirement | Source | Check |
| --- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | --------------------------------------- |
| D1 | `templates/marketplace.example.json` has marketplace identity, plugin name/category, a local distribution source, and valid policies | Codex marketplace schema | [automated] `assertMarketplaceTemplate` |
| D2 | Optional repo-level `.agents/plugins/marketplace.json` points at `./packages/twenty-agent-skills/dist` | Build guide §Distribution | [automated] `assertJsonMetadata` |
| D3 | `CHANGELOG.md` updated for every version bump | Reference impl | [manual] release reviewer sign-off |
## Process
| # | Requirement | Source | Check |
| --- | --------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------- |
| P1 | `validate-source.js` runs in CI on every PR touching the plugin | Reference impl | [manual] confirm `.github/workflows/ci-agent-skills.yaml` is active |
| P2 | `validate-source.js` has its own unit tests | Internal convention | [manual] confirm `scripts/__tests__/` exists and runs |
| P3 | Version bump procedure documented | Internal convention | [manual] confirm `CONTRIBUTING.md` covers it |
## Manual Sign-off Log
When a manual row above is verified, add a line here.
| Date | Row(s) | Reviewer | Notes |
| ---------- | ------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-05-28 | S6, S8 | Codex plugin compliance pass | Skill descriptions reviewed side-by-side; "When To Use" sections added to all five SKILL.md files with explicit "do not use this skill for X" boundary callouts referencing the four siblings. Routing surface is disjoint. |
| 2026-05-28 | M12 | Codex plugin compliance pass | `brandColor: #000000` matches the actual logo background in `assets/twenty-logo.svg`. |
## Deferred Items
Tracked for a follow-up PR; not blocking the current release.
| Item | Reason for deferral |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Real marketplace screenshots (A4, A5, A6) | Requires real captures against a demo workspace. `assets/screenshots/README.md` documents what's needed; `plugin.json` `screenshots` stays `[]` until the PNGs land so `assertAssets` does not regress. |
| Splitting `standalone-pages.md` / `front-component-ui.md` | The cross-doc contracts assert dozens of exact fragments per file. The mechanical risk of fragment drift outweighs the readability gain at current size (~450 lines each). Re-evaluate when either exceeds 600 lines. |
| `hooks/hooks.json` SessionStart hook | The Codex hook schema for plugin-bundled hooks needs deeper validation; a misconfigured `SessionStart` hook would fire on every Codex session. Add only when the format is verified against a working reference implementation. |

View File

@@ -0,0 +1,59 @@
# Contributing
This package is the canonical source for Twenty app skills and the Codex plugin. Agents using the plugin should read [`AGENTS.md`](./AGENTS.md).
## Source and Distribution
Edit `skills/<name>/SKILL.md` and shared `references/` directly. Keep this content harness-neutral. Codex-specific behavior belongs in `.codex-plugin/`, `.mcp.json`, `agents/openai.yaml`, and `scripts/setup-mcp.sh`.
`build` creates ignored `dist/` with self-contained skill directories, the shared references needed by `AGENTS.md`, and Codex metadata. Both the `skills` CLI and Codex install that distribution. Never edit or commit generated files to `main`.
The main-only publishing workflow validates and builds this package, then publishes the contents of `dist/` at the root of the `agent-skills` branch. That branch is created on the first successful publish after merge. Until then, install from the local build as described in [`README.md`](./README.md#local-development). Codex marketplace entries should select this same published branch; the local template selects `./packages/twenty-agent-skills/dist`.
## Checks
Run from the repository root:
```bash
npx nx run twenty-agent-skills:validate
npx nx run twenty-agent-skills:build
npx nx run twenty-agent-skills:test
```
All must pass before merge. Build validation checks that references remain within each installed skill. Scripts use Node built-ins only; no new runtime dependencies.
For an actual installation check, install the built distribution into an isolated directory with the `skills` CLI, then verify its installed copies:
```bash
npx nx run twenty-agent-skills:verify:install -- "<install-directory>" create-app
```
Omit skill names to verify all five. CI exercises both single-skill and all-skill installs. Changes affecting scaffold or sync guidance also need the manual procedure in [`SMOKE-TEST.md`](./SMOKE-TEST.md).
## Adding a Skill
1. Create `skills/<name>/SKILL.md` (frontmatter: `name`, `description` only) and `skills/<name>/agents/openai.yaml` (`display_name`, `short_description` ≤ 64, `default_prompt` mentioning `$<name>`).
2. Add a `## When To Use` section with 4–6 user-language triggers and “do not use this skill for X” callouts referencing siblings.
3. Add the name to `CANONICAL_SKILL_NAMES` in `scripts/validators/lib.js`.
## Adding a Reference
1. Place it under `references/<area>/<name>.md` and link it from the skills that need it. The build follows these links to bundle each skill's references.
2. Add the path to `REQUIRED_REFERENCES` in `scripts/validators/references.js`.
3. If the file participates in a cross-doc contract, update `scripts/validators/cross-doc-contracts.js` in the same commit.
## Bumping the Version
Update `package.json` and `.codex-plugin/plugin.json` together. Then move unreleased entries in `CHANGELOG.md` under a new `[X.Y.Z] - YYYY-MM-DD` heading.
SemVer: **patch** for copy/validation fixes, **minor** for new references/rules/skill sections, **major** for renaming a canonical skill or breaking the frontmatter/agents.yaml shape.
The public Codex plugin identifier remains `twenty`; it does not follow the workspace package name.
## Editing Validators
Add source assertions in the appropriate `scripts/validators/*.js` module and call them from `scripts/validate-source.js`. Add passing and failing fixtures in `scripts/__tests__/source-validation.spec.js`. Distribution checks live in `scripts/validate-distribution.js`, with fixtures in `scripts/__tests__/distribution-validation.spec.js`.
## PRs
One concern per PR. Title prefix: `feat|fix|docs|chore(agent-skills):`. Mention the relevant checks and any [`CHECKLIST.md`](./CHECKLIST.md) rows touched.

View File

@@ -0,0 +1,105 @@
# Twenty Agent Skills
Official [Agent Skills](https://skills.sh/) for building Twenty apps, shared by the `skills` CLI and the Codex plugin named `twenty`.
This package owns the five canonical skills, shared references, and Codex metadata. One build produces a distribution with self-contained skill directories for both installation methods. Generated files live in ignored `dist/` locally and on the `agent-skills` publishing branch, never in the source tree on `main`.
## Installation
### Agent Skills
Install into Claude Code, Codex, Cursor, Pi, or any other harness supported by the `skills` CLI:
```bash
# List the available skills
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --list
# Install one skill
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --skill create-app
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --skill develop-app
# Install everything
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --skill '*'
```
The CLI prompts for target agents, or accepts `--agent` (for example `--agent claude-code`, `--agent codex`, or `--agent '*'`). Each installed skill includes the references it needs.
**First publish:** the `agent-skills` branch is created by the first successful publish from `main`. Before that workflow has run, use the [local build](#local-development) below. The source package on `main` is not directly installable with the `skills` CLI because its references are shared across skills.
Use the branch URL above to select the app-development collection. The bare `twentyhq/twenty` repository also contains contributor and app-specific skills; see the repository's [`SKILLS.md`](https://github.com/twentyhq/twenty/blob/main/SKILLS.md) for those audiences.
### Codex Plugin
Search for “Twenty” in the Codex plugin directory and install. The plugin keeps its public identifier, `twenty`, and consumes the same built distribution as the `skills` CLI. It also includes the public Twenty documentation MCP server and a workspace MCP setup helper.
For a checkout or an unpublished change, use the local marketplace instructions below.
## Skills
| Skill | Use it for |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| [`create-app`](./skills/create-app/SKILL.md) | Scaffold a new Twenty app with `create-twenty-app`. |
| [`develop-app`](./skills/develop-app/SKILL.md) | Add or modify objects, fields, logic functions, layouts, front components, workflows. |
| [`manage-app`](./skills/manage-app/SKILL.md) | Manage remotes, sync, build, deploy, logs, troubleshooting, CI/CD. |
| [`publish-app`](./skills/publish-app/SKILL.md) | Prepare README, marketplace metadata, logos, screenshots, public assets. |
| [`use-twenty-mcp`](./skills/use-twenty-mcp/SKILL.md) | Connect to a workspace over MCP and present records as readable Markdown. |
The four app-development skills work without MCP setup. Self-hosted instances, custom HTTPS domains, and localhost HTTP URLs are supported. Workspace URLs and credentials stay user-local.
Cross-skill operating rules live in [`references/concepts/operating-rules.md`](./references/concepts/operating-rules.md). [`AGENTS.md`](./AGENTS.md) adds Codex-specific routing and wrapper guidance.
## MCP Setup
The Codex plugin bundles the public `twenty-docs` server for searching official Twenty documentation. Workspace data access requires each user's own MCP endpoint in their private client configuration.
From a repository checkout:
```bash
bash packages/twenty-agent-skills/scripts/setup-mcp.sh myworkspace.twenty.com
```
The helper accepts Twenty subdomains, custom domains, and localhost. It normalizes the URL to an `/mcp` endpoint and names the server after its host, such as `twenty-myworkspace`. Codex may open OAuth automatically; if it does not, run `codex mcp login <server-name>`. Use `--force-login` only for terminal-only setup.
Equivalent manual setup:
```bash
codex mcp add twenty-myworkspace --url https://myworkspace.twenty.com/mcp
codex mcp login twenty-myworkspace
```
The same helper is included at `scripts/setup-mcp.sh` in the built distribution. For other clients, see [`references/use-twenty-mcp/setup.md`](./references/use-twenty-mcp/setup.md).
Workspace MCP URLs and ChatGPT app declarations are user-local. This distribution ships no `.app.json`; a workspace MCP URL alone is not a ChatGPT connector ID.
## Local Development
Run from the repository root:
```bash
# Validate canonical skills and Codex metadata
npx nx run twenty-agent-skills:validate
# Build the shared distribution into ignored dist/
npx nx run twenty-agent-skills:build
# Install from that distribution
npx skills add ./packages/twenty-agent-skills/dist --skill create-app
# Run build and validation tests
npx nx run twenty-agent-skills:test
```
For local Codex plugin installation, build first, then copy the marketplace template (or merge its entry into an existing config):
```bash
mkdir -p .agents/plugins
cp packages/twenty-agent-skills/templates/marketplace.example.json .agents/plugins/marketplace.json
```
The template points at `./packages/twenty-agent-skills/dist`. Enable it through the Codex plugin manager. Adjust the path if the marketplace config is outside this checkout.
Edit `skills/` and `references/` directly; they are the canonical source. Keep content harness-neutral and Codex-specific settings in `.codex-plugin/`, `.mcp.json`, `agents/openai.yaml`, and `scripts/setup-mcp.sh`. Rebuild after edits; do not commit `dist/`.
The publish workflow builds and validates `main`, then publishes `dist/` at the root of the `agent-skills` branch. Pull requests validate the distribution without publishing it. See [`CONTRIBUTING.md`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-agent-skills/CONTRIBUTING.md) for the full checks and release process, [`CHECKLIST.md`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-agent-skills/CHECKLIST.md) for Codex checks, and [`CHANGELOG.md`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-agent-skills/CHANGELOG.md) for changes.
Installation is checked in CI. The workflow against a running self-hosted Twenty instance is checked separately; see [`SMOKE-TEST.md`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-agent-skills/SMOKE-TEST.md) for the procedure and its recorded run.

View File

@@ -0,0 +1,82 @@
# Self-Hosted Smoke Test
The portable skills claim that a user can scaffold and develop a Twenty app against a self-hosted instance. This is how that claim is checked by hand. CI covers installation (`ci-agent-skills.yaml`); this covers the workflow the installed skills describe, which needs a running Twenty instance and cannot run on a CI runner.
Run it when `create-app`, `develop-app`, or `manage-app` guidance changes in a way that affects the scaffold or sync flow.
## Install the Distribution
Build from a checkout and install the skills into the agent used for this run:
```bash
npx nx run twenty-agent-skills:build
npx skills add ./packages/twenty-agent-skills/dist --skill create-app develop-app manage-app
```
After the first successful publish from `main`, the same distribution is available at `https://github.com/twentyhq/twenty/tree/agent-skills`. Record the source revision and installation method with each new run. The recorded run below predates the distribution migration and documents the app workflow only.
## 1. Bring up a self-hosted instance
Any instance works. For a local one from a checkout of this repository:
```bash
bash packages/twenty-utils/setup-dev-env.sh
npx nx database:reset twenty-server # init only builds the schema; reset also seeds
npx nx start twenty-server
```
Wait for `http://localhost:3000/healthz` to return 200. This instance is self-hosted for the purposes of the test: a non-`twenty.com` host, over plain HTTP, on a port the user chose.
## 2. Authenticate a remote
The scaffolder reuses an existing remote's credentials when one matches the target URL, which is what makes the rest of the run non-interactive. Add one with an API key from the instance's settings:
```bash
yarn twenty remote:add --url http://localhost:3000 --api-key <api-key> --as selfhosted
```
`DEV_API_KEY` in `twenty-sdk` does not work here. It is signed for the `twenty-app-dev` Docker image's `APP_SECRET`, so a locally built server with its own secret rejects it as `Token invalid`.
## 3. Scaffold, following `create-app`
```bash
npx create-twenty-app@latest smoke-crm --url http://localhost:3000 \
--name smoke-crm --display-name "Smoke CRM" --description "Self-hosted smoke test app"
```
Expect `Authenticating -> Reusing existing credentials`, then `Application created successfully`. Confirm the app registered on the instance rather than only on disk:
```sql
select id, "universalIdentifier", version from core.application;
```
One row must carry the `APPLICATION_UNIVERSAL_IDENTIFIER` from the generated `src/constants/universal-identifiers.ts`.
## 4. Develop, following `develop-app`
```bash
cd smoke-crm
yarn twenty dev:add object # interactive; needs a TTY
yarn twenty apply
```
`apply` prints a plan and then `Synced <app name>`. Confirm the entity reached the instance:
```sql
select "nameSingular", "namePlural" from core."objectMetadata" where "nameSingular" = 'ticket';
select table_schema || '.' || table_name from information_schema.tables where table_name ilike '%ticket%';
```
Both must return a row. The second proves the workspace migration ran and the physical table exists, which a successful sync message alone does not.
## Recorded run
Executed against a locally built server on `http://localhost:3000`, Node 24.16.0:
- Scaffold reused the `selfhosted` remote credentials, no browser OAuth, and reported `Application created successfully`.
- `core.application` carried the scaffolded identifier `e606ca1a-4035-4c20-87ac-f49e5af6f05c` at `0.1.0`.
- `dev:add object` generated `src/objects/ticket.ts` plus its view, record-page fields, navigation menu item, and record page layout.
- `yarn twenty apply` reported `Plan: 50 to add, 0 to change, 0 to destroy` and `Synced Smoke CRM (5 files)`.
- `core."objectMetadata"` held `ticket` / `tickets`, `core."navigationMenuItem"` held `ticket`, and the physical table `_ticket` existed in the workspace schema.
Note for whoever runs this next: `create-twenty-app` still calls `yarn twenty dev --once` internally during its install step, while the skills direct agents to `yarn twenty apply`. That is the scaffolder's own behavior, not skill guidance.

View File

@@ -0,0 +1,29 @@
# Marketplace Screenshots
The Codex marketplace listing for the Twenty plugin requires ≥ 1 screenshot showing the plugin in action (Build guide §Assets).
## What to Capture
Three screenshots, all PNG, at marketplace resolution (preferably 1600×1000 or higher, 16:10):
1. **`01-create-app.png`** — `create-app` mid-flow: user prompt asking to scaffold a new Twenty app, the agent asking for the instance URL, and the `create-twenty-app` output visible.
2. **`02-develop-and-sync.png`** — `develop-app` adding an entity through `yarn twenty dev:add`, followed by `yarn twenty apply` syncing successfully.
3. **`03-use-twenty-mcp.png`** — `use-twenty-mcp` retrieving a workspace records table where the record-name column is rendered as a linked Markdown table.
## After Adding the Files
1. Drop the three PNGs into this directory using the exact filenames above.
2. Update `.codex-plugin/plugin.json` `interface.screenshots`:
```json
"screenshots": [
"./assets/screenshots/01-create-app.png",
"./assets/screenshots/02-develop-and-sync.png",
"./assets/screenshots/03-use-twenty-mcp.png"
]
```
3. Run `npx nx run twenty-agent-skills:validate` — the planned `assertAssets` check confirms every referenced screenshot exists and is PNG.
4. Tick rows A4, A5, A6 in `CHECKLIST.md` with the reviewer's date.
## What Not to Include
No customer data, real workspace URLs, real API keys, or unreleased confidential content. Use the bundled local Docker instance or a dedicated demo workspace for captures.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="11.293" fill="#000000"/>
<path d="M19.25 35.746C19.25 30.5 23.508 26.246 28.75 26.246H47.039C47.309 26.246 47.555 26.406 47.668 26.652C47.781 26.902 47.73 27.191 47.547 27.395L43.539 31.75C42.84 32.508 41.859 32.945 40.828 32.945H28.801C27.227 32.945 25.949 34.223 25.949 35.797V42.98C25.949 43.906 25.199 44.652 24.273 44.652H20.93C20.004 44.652 19.258 43.906 19.258 42.98V35.746Z" fill="white"/>
<path d="M76.152 60.254C76.152 65.5 71.895 69.754 66.648 69.754H58.879C53.633 69.754 49.375 65.5 49.375 60.254V46.652C49.375 45.727 49.723 44.836 50.352 44.152L54.883 39.234C55.074 39.027 55.371 38.957 55.637 39.055C55.898 39.164 56.074 39.41 56.074 39.691V60.211C56.074 61.785 57.352 63.063 58.926 63.063H66.605C68.18 63.063 69.457 61.785 69.457 60.211V35.797C69.457 34.223 68.18 32.945 66.605 32.945H57.676C56.652 32.945 55.68 33.375 54.98 34.121L28.348 63.063H44.352C45.273 63.063 46.023 63.813 46.023 64.738V68.082C46.023 69.008 45.273 69.754 44.352 69.754H22.785C20.832 69.754 19.242 68.168 19.242 66.211V64.441C19.242 63.551 19.574 62.695 20.18 62.039L50.039 29.605C52.016 27.457 54.789 26.246 57.707 26.246H66.641C71.887 26.246 76.145 30.5 76.145 35.746V60.254Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,30 @@
{
"name": "twenty-agent-skills",
"version": "0.1.0",
"description": "Harness-agnostic Agent Skills for creating, developing, operating, and publishing Twenty apps.",
"private": true,
"type": "module",
"license": "AGPL-3.0",
"scripts": {
"build": "node ./scripts/build.js",
"validate": "node ./scripts/validate-source.js && node ./scripts/build.js && node ./scripts/validate-distribution.js",
"test": "node --test ./scripts/__tests__/*.spec.js",
"verify:install": "node ./scripts/verify-install.js",
"setup:mcp": "bash ./scripts/setup-mcp.sh"
},
"files": [
".codex-plugin",
".mcp.json",
"AGENTS.md",
"README.md",
"assets",
"references",
"skills",
"scripts/setup-mcp.sh"
],
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
}
}

View File

@@ -0,0 +1,99 @@
{
"name": "twenty-agent-skills",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-agent-skills",
"projectType": "library",
"tags": ["scope:agent-skills"],
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "node ./scripts/build.js"
},
"inputs": [
"{projectRoot}/.codex-plugin/**/*",
"{projectRoot}/.mcp.json",
"{projectRoot}/*.md",
"{projectRoot}/assets/**/*",
"{projectRoot}/package.json",
"{projectRoot}/references/**/*",
"{projectRoot}/scripts/**/*",
"{projectRoot}/skills/**/*",
"{projectRoot}/templates/**/*"
],
"outputs": ["{projectRoot}/dist"]
},
"validate": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "node ./scripts/validate-source.js && node ./scripts/build.js && node ./scripts/validate-distribution.js"
},
"inputs": [
"{projectRoot}/.codex-plugin/**/*",
"{projectRoot}/.mcp.json",
"{projectRoot}/*.md",
"{projectRoot}/assets/**/*",
"{projectRoot}/package.json",
"{projectRoot}/references/**/*",
"{projectRoot}/scripts/**/*",
"{projectRoot}/skills/**/*",
"{projectRoot}/templates/**/*",
"{projectRoot}/.gitignore",
"{workspaceRoot}/.agents/plugins/marketplace.json"
],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "node --test ./scripts/__tests__/*.spec.js"
},
"inputs": [
"{projectRoot}/.codex-plugin/**/*",
"{projectRoot}/.mcp.json",
"{projectRoot}/*.md",
"{projectRoot}/assets/**/*",
"{projectRoot}/package.json",
"{projectRoot}/references/**/*",
"{projectRoot}/scripts/**/*",
"{projectRoot}/skills/**/*",
"{projectRoot}/templates/**/*",
"{projectRoot}/.gitignore",
"{workspaceRoot}/.agents/plugins/marketplace.json"
]
},
"verify:install": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "node ./scripts/verify-install.js"
}
},
"fmt": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
},
"configurations": {
"ci": {},
"fix": {
"command": "prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
}
},
"setup:mcp": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "bash ./scripts/setup-mcp.sh"
}
}
}
}

View File

@@ -0,0 +1,110 @@
# How Twenty Apps Work
Use this reference to understand the Twenty application development model before creating, developing, or deploying an app. Other references cover specific workflows; this one explains the architecture behind them.
## What Is A Twenty App
A Twenty app is a standalone npm package that extends a Twenty instance. It is not part of the Twenty server codebase. It lives in its own directory with its own `package.json`, dependencies, and source tree. When installed on a Twenty instance, it adds objects, fields, views, page layouts, front components, logic functions, skills, agents, and more to that workspace.
The app does not run as a separate server. It is built, published, and installed into a running Twenty instance. The instance loads the app's entities into its schema and renders the app's front components inside the workspace UI.
## SDK Packages
A Twenty app depends on two SDK packages:
| Package | Purpose | Used in |
| --- | --- | --- |
| `twenty-sdk` | Define app entities and access front component runtime APIs | Entity definitions (`twenty-sdk/define`), front component hooks and host APIs (`twenty-sdk/front-component`) |
| `twenty-client-sdk` | Access workspace data from front components | Core object queries (`twenty-client-sdk/core`), metadata queries (`twenty-client-sdk/metadata`) |
`twenty-sdk/define` provides the registration functions: `defineApplication`, `defineObject`, `defineField`, `defineView`, `definePageLayout`, `defineFrontComponent`, `defineNavigationMenuItem`, `defineLogicFunction`, `defineRole`, and others. Every app entity is declared through one of these functions.
`twenty-sdk/front-component` provides runtime APIs available inside front components: `navigate`, `enqueueSnackbar`, `openSidePanelPage`, `useSelectedRecordIds`, `getApplicationVariable`, and others.
Twenty UI components (`Button`, `Chip`, `Tag`, `Status`, `Heading`, `Section`, `ThemeProvider`, icons, `themeCssVariables`) live in the `twenty-ui` package. Install `twenty-ui` from npm at the same version as `twenty-sdk` and `twenty-client-sdk` and import from its subpaths (`twenty-ui/primitives/input`, `twenty-ui/primitives/data-display`, `twenty-ui/icon`, `twenty-ui/primitives/typography`, `twenty-ui/components`, `twenty-ui/theme-constants`, and others).
`twenty-client-sdk/core` provides `CoreApiClient` for querying and mutating workspace records (companies, people, custom objects). `twenty-client-sdk/metadata` provides access to workspace metadata (object definitions, field definitions).
## Twenty Instances And Remotes
A Twenty instance is a running Twenty server — either self-hosted or managed cloud. Each instance hosts one or more workspaces. Apps are installed per workspace.
A "remote" is the local CLI's connection to a Twenty instance. It stores the server URL and an API key in `~/.twenty/config.json`. You can configure multiple remotes (e.g. `local`, `staging`, `production`) and switch between them.
The API key authenticates the developer against the target workspace. It determines which workspace the app syncs to, deploys to, and reads data from during development.
## Local Development Environment
An app needs a running Twenty instance to develop against. There are two ways to connect:
1. **Existing instance via OAuth** (default): `create-twenty-app --url <twenty-instance-url>` authenticates via OAuth on the provided Twenty server. The scaffolder opens a browser for the OAuth flow, then stores the credentials as a remote in `~/.twenty/config.json`. This is best when the developer already has a workspace with data to develop against.
2. **Local instance with Docker** (fallback): `create-twenty-app` (no `--url`) starts a disposable local Twenty server on `http://localhost:2020` through Docker. The scaffolder authenticates with the local server's development API key and creates a `local` remote automatically. Use this when the developer has no Twenty instance available and wants to start fresh without affecting a shared workspace.
In both cases, the result is a remote stored in `~/.twenty/config.json` that the CLI uses for all subsequent sync, deploy, and data access commands.
## App Lifecycle
The development loop is:
```
create → develop → sync → validate → repeat
```
1. **Create**: `npx create-twenty-app@latest <app-name>` generates the project, installs dependencies, starts a local Twenty server, and runs an initial sync. The scaffolder handles everything in one command — do not run `yarn twenty apply` after scaffolding because the sync already happened.
2. **Develop**: Add or modify entities in `src/`. Objects go in `src/objects/`, front components in `src/front-components/`, logic functions in `src/logic-functions/`, page layouts in `src/page-layouts/`, and so on. Each entity file exports a `define*` call.
3. **Sync**: `yarn twenty apply` builds, deploys, and installs the app on the active remote in one step. The Twenty instance updates its schema, registers new objects and fields, mounts front components, and activates logic functions. This is the primary way to get changes onto a Twenty instance during development. Sync after every meaningful change.
4. **Validate**: `yarn twenty dev:typecheck` checks generated types. `yarn lint` checks local lint rules. Open the workspace in a browser to verify front components render and logic functions execute.
## Sharing An App
Publishing is separate from the development loop. It is used to share an app with other Twenty instances or the public marketplace:
- `yarn twenty app:publish` publishes to npm for the public marketplace.
- `yarn twenty app:publish --private --remote <name>` publishes privately to a specific Twenty instance's registry.
Each publish requires a strictly higher semver version in `package.json`. See the `publish-app` skill for marketplace metadata and listing guidance.
## Front Component Rendering
Front components do not run as a separate frontend application. The Twenty workspace renders them inside an isolated Remote DOM container. The SDK mounts the component, provides a `ThemeProvider`, and proxies host APIs (navigation, snackbars, side panels) through `twenty-sdk/front-component`.
This means:
- Front components cannot access `document` or `window` globals directly.
- They cannot use React portals or third-party libraries that manipulate the DOM outside their tree.
- They import UI primitives from `twenty-ui` (installed from npm), not from external component libraries.
- They fetch workspace data through `twenty-client-sdk/core`, not through direct API calls.
## App File Structure
A typical Twenty app after scaffolding:
```
my-app/
package.json # App metadata, version, dependencies
src/
application-config.ts # defineApplication() — app identity and marketplace metadata
constants/
universal-identifiers.ts # Stable UUIDs for all entities
objects/ # defineObject, defineField, defineRelation
views/ # defineView
roles/ # defineRole
front-components/ # defineFrontComponent (.tsx)
page-layouts/ # definePageLayout
navigation-menu-items/ # defineNavigationMenuItem
logic-functions/ # defineLogicFunction
public/ # Static assets (logos, screenshots, images)
.github/workflows/ # CI/CD automation (optional)
```
`application-config.ts` is the app's entry point. It calls `defineApplication()` with the app's universal identifier, display name, description, default role, and marketplace metadata. Every entity references stable UUIDs from `constants/universal-identifiers.ts`.
## Key Concepts
- **Universal identifiers**: Stable UUIDs assigned to every entity. They survive renames, version bumps, and resyncs. Never change a universal identifier after first sync.
- **Remotes**: Named connections to Twenty instances. Stored in `~/.twenty/config.json`. Switch with `yarn twenty remote:use <name>`.
- **Sync**: `yarn twenty apply` builds, deploys, and installs the app on the active remote in one step. This is the standard way to get code changes onto a Twenty instance.
- **Publish**: `yarn twenty app:publish` packages the app for distribution to other instances or the marketplace. Requires a strictly higher semver version than the previously published version.

View File

@@ -0,0 +1,17 @@
# Operating Rules
These rules apply to every Twenty app task, in every skill and every agent harness. They exist because they have failed before.
This file is the single source of truth for the cross-skill rules. Harness-specific entry points link here instead of restating them, so the rules cannot drift between distributions.
1. **Bounded sync only.** Use `yarn twenty apply` to synchronize app changes. Never `yarn twenty dev` (watch mode). Watch mode leaks file handles and produces ambiguous failure output in agent sandboxes.
2. **Do not run broad validation unless it is requested.** After scaffolding (`create-twenty-app`) or after the CLI generates entities, prefer the bounded command that matches the task: `yarn twenty apply` for app sync, the package's unit-test script for unit tests, and `TWENTY_API_URL=http://localhost:2021 yarn test` for the full integration suite. Integration tests must target the isolated test instance on port `2021`, not the dev instance on port `2020`, unless the user explicitly asks otherwise.
3. **Use `yarn twenty dev:add` for new entities.** It generates correct file structure, UUIDs, SDK imports, and boilerplate. Do not hand-craft entity files unless modifying existing ones or the CLI does not support that entity type.
4. **Confirm destructive operations.** Deploys to production, uninstalls, production remote changes, and production syncs require explicit user confirmation before execution. Treat `--remote production` as user-visible.
5. **Any Twenty instance is a valid target.** The user supplies the workspace or instance URL: managed cloud, self-hosted on a custom HTTPS domain, or localhost over HTTP. Never assume or require a `twenty.com` workspace.
6. **Workspace URLs and credentials stay user-local.** Never bundle workspace-specific MCP URLs in shared or committed files. Bearer tokens, API keys, and OAuth secrets belong in the user's private client config; `TWENTY_DEPLOY_API_KEY` and similar live in CI secret stores, never committed in source.

View File

@@ -0,0 +1,452 @@
# When to Use
Use this when the user wants to design or polish a Twenty front component UI.
Examples:
- Make a front component look better.
- Create a clean layout for a front component.
- Improve loading, empty, error, or disabled states.
- Make a front component responsive and easier to scan.
# Boundaries
Do not scaffold a new app here. Use `create-app` first when the app does not exist.
Do not use this reference for source files, registration, runtime imports, data access, CLI commands, or browser verification.
# Design Rules
Design front components as compact CRM product UI, not marketing pages. The goal is a surface that scans quickly, feels native inside Twenty, and keeps actions predictable.
Use Figma measurements as evidence for the current Twenty rhythm, not as implementation literals. Source code should use Twenty primitives, component props, and `themeCssVariables` instead of hardcoded visual values.
## Twenty UI Defaults
Prefer Twenty UI primitives for CRM-native front component UI:
- Use `Dialog.Title` from `twenty-ui/primitives/surfaces` inside `Modal` for its visible title and accessible name.
- Use `Heading` from `twenty-ui/primitives/typography` for compact headings, or `Section.Header` from `twenty-ui/components` for a heading with a description or action.
- Use `Callout` with a matching icon for loading, empty, error, and blocked states.
- Use `Button` for primary and secondary actions.
- Use `Tag`, `Status`, `Chip`, `Label`, and `Avatar` for metadata, state, people, and small summaries.
- Use `themeCssVariables` for spacing, colors, border radius, typography, borders, icon sizing, shadows, and backgrounds.
Use local inline styles for layout containers and custom data displays, but keep them aligned with Twenty tokens. Use `../develop-app/front-components.md` for exact imports and runtime rules.
Use Twenty UI icons before custom SVG. Use `themeCssVariables.icon.size.md` for default row and action icons, `themeCssVariables.icon.size.sm` for quiet chevrons, and `themeCssVariables.spacing[6]` for icon-only action targets.
## Token-First Implementation
When turning a Figma observation into code, translate the visual target into a token:
- Typography uses `themeCssVariables.font.size.*`, `themeCssVariables.font.weight.*`, and `themeCssVariables.text.lineHeight.*`.
- Spacing uses `themeCssVariables.spacing[...]` and `themeCssVariables.betweenSiblingsGap`.
- Radius uses `themeCssVariables.border.radius.sm` for compact controls and `themeCssVariables.border.radius.md` for cards or panels.
- Colors use `themeCssVariables.font.color.*`, `themeCssVariables.background.*`, `themeCssVariables.border.color.*`, or component props.
- Icons use `themeCssVariables.icon.size.*` or native Twenty UI icon sizing.
- Borders use `themeCssVariables.border.color.*`; avoid raw border colors.
- Shadows use `themeCssVariables.boxShadow.*`; avoid custom shadow stacks unless mirroring an existing Twenty overlay exactly.
Do not create source constants named after raw visual measurements. Use semantic names that point to tokens:
```tsx
const compactControlHeight = themeCssVariables.spacing[6];
const denseRowHeight = themeCssVariables.spacing[8];
const panelRadius = themeCssVariables.border.radius.md;
```
Avoid source constants named after raw measurements, such as `rowHeight` or `cardRadius`, when they hide a token choice. Prefer semantic names like `denseRowHeight` and `panelRadius`.
## Base Scale
Use the same small scale across the whole component:
- Body text: `themeCssVariables.font.size.md`, regular weight, medium line height.
- Section titles and row titles: `themeCssVariables.font.size.md`, medium weight, medium line height.
- Descriptions and helper text: `themeCssVariables.font.size.sm`, regular weight, medium line height.
- Metadata labels and table headers: medium body text with `themeCssVariables.font.color.tertiary`.
- Compact controls: use Twenty `Button` sizing or `themeCssVariables.spacing[6]` for custom control height.
- Navigation and menu rows: use the native component when available; otherwise use the nearest theme spacing token that matches the host row rhythm.
- Dense table, list, and input-like rows: use `themeCssVariables.spacing[8]` for custom row height.
- Toolbars and view bars: use `themeCssVariables.spacing[10]` for custom toolbar height.
Do not increase type size to create hierarchy inside compact widgets. Use weight, color, alignment, spacing, and order first.
Applied example:
```tsx
const titleStyle = {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
lineHeight: themeCssVariables.text.lineHeight.md,
letterSpacing: 0,
color: themeCssVariables.font.color.primary,
};
const descriptionStyle = {
fontSize: themeCssVariables.font.size.sm,
fontWeight: themeCssVariables.font.weight.regular,
lineHeight: themeCssVariables.text.lineHeight.md,
letterSpacing: 0,
color: themeCssVariables.font.color.secondary,
};
```
## Radius And Shape
Use a small, consistent radius system:
- Cards, popovers, notification containers, and framed panels use `themeCssVariables.border.radius.md`.
- Buttons, chips, toolbar controls, menu hover fills, skeletons, and small fields use `themeCssVariables.border.radius.sm`.
- Checkbox shape and target sizing should come from the native Twenty checkbox pattern when available.
- Do not use pill or rounded radius for cards, rows, or toolbar buttons unless the existing Twenty primitive already does.
- Do not mix several custom radii in one component. Pick the matching Twenty radius token for each element role.
Applied example:
```tsx
const cardStyle = {
borderRadius: themeCssVariables.border.radius.md,
padding: themeCssVariables.spacing[2],
background: themeCssVariables.background.primary,
};
const toolbarButtonStyle = {
minHeight: themeCssVariables.spacing[6],
padding: `0 ${themeCssVariables.spacing[2]}`,
borderRadius: themeCssVariables.border.radius.sm,
};
```
## Spacing
Use the theme spacing scale instead of raw dimensions:
- `themeCssVariables.betweenSiblingsGap`: tightly grouped toolbar actions or small separators.
- `themeCssVariables.spacing[1]`: icon-to-label gaps, chip internals, compact row gaps.
- `themeCssVariables.spacing[2]`: default card padding, row horizontal padding, toolbar padding, title-row gaps.
- `themeCssVariables.spacing[4]`: section gaps inside larger panels or between unrelated groups.
- `themeCssVariables.spacing[6]`: left indentation for description text that belongs to a row with a leading icon and label.
Do not jump to large padding inside ordinary CRM controls. If a compact widget starts needing generous whitespace, the layout probably needs fewer sections or better grouping.
Applied example:
```tsx
const titleRowStyle = {
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
minHeight: themeCssVariables.spacing[6],
};
const descriptionRowStyle = {
paddingLeft: themeCssVariables.spacing[6],
paddingBottom: themeCssVariables.spacing[2],
};
```
## Information Hierarchy
Make hierarchy visible without making the component loud:
- Put the record, object, or task name first. Use medium body text with primary color.
- Put the explanation second. Use small regular text with secondary color.
- Put metadata third. Use `Tag`, `Status`, `Chip`, `Label`, `Avatar`, or tertiary text.
- Put counters and quiet totals in light text, not primary text.
- Use one primary action per small surface. Put secondary actions beside it or behind a menu.
- Use color for state and meaning, not decoration.
- Keep descriptions aligned under the title text, not under the leading icon.
- In a card with a leading icon, use a medium icon, a standard spacing gap, then text. The description starts at the same x-position as the title text.
- In a list or table, reserve the left edge for the most important identifier and keep it stable across every row.
Good hierarchy:
```tsx
<div style={{ display: 'grid', gap: themeCssVariables.spacing[1] }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
}}
>
<Avatar name={ownerName} />
<span style={titleStyle}>{companyName}</span>
<Status color="green" text="Active" />
</div>
<span style={descriptionStyle}>Renewal due in 12 days</span>
</div>
```
Avoid hierarchy that relies on oversized type, all caps, bright backgrounds, or extra padding. That breaks the compact CRM rhythm and makes dense data harder to scan.
## Alignment
Twenty UI looks precise because repeated edges line up:
- Align every custom surface to the theme spacing grid.
- Use `themeCssVariables.spacing[2]` horizontal padding for compact rows and cards.
- Use `themeCssVariables.spacing[6]` for icon-only action targets so icons share a stable center.
- Align row icons, avatars, labels, and actions vertically center.
- Keep list text left-aligned. Do not center-align row content.
- Keep toolbar context on the left and actions on the right.
- Keep repeated rows the same x-position, width, height, and action position.
- In menus, use a small inset from the menu edge and keep rows full width inside that inset.
- In tables, use the table theme tokens when available, especially `themeCssVariables.table.horizontalCellPadding`.
- Do not let a long value push action buttons out of alignment. Truncate text before actions move.
Toolbar pattern:
```tsx
const toolbarStyle = {
minHeight: themeCssVariables.spacing[10],
padding: themeCssVariables.spacing[2],
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: themeCssVariables.spacing[2],
};
const toolbarActionsStyle = {
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.betweenSiblingsGap,
};
```
Table/list row pattern:
```tsx
const rowStyle = {
minHeight: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.table.horizontalCellPadding}`,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
columnGap: themeCssVariables.spacing[2],
};
const cellLabelStyle = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
```
## Consistency
Keep visual decisions repeatable:
- Use the same row height token for every row in the same list.
- Use the same action placement in loading, empty, error, and ready states.
- Use the same radius token for the same kind of element everywhere in the component.
- Use the same icon size token for the same role.
- Use the same label for the same action everywhere. Do not mix "Open", "View", and "See record" for one action.
- Use the same empty-state structure across related components: icon, short title, one sentence, one action.
- Use the same loading skeleton geometry as the final content. Do not replace compact rows with centered spinners unless the whole panel is blocked.
- Use Twenty tokens for color and spacing. Use raw values only for semantic data visualization that cannot be represented by existing tokens.
State backgrounds should follow the existing Twenty pattern:
- Default: transparent or `themeCssVariables.background.primary`.
- Hover: `themeCssVariables.background.transparent.light` or `themeCssVariables.background.secondary`.
- Pressed/clicked: `themeCssVariables.background.tertiary`.
- Selected: same shape and height as the default row, with selected color treatment from the nearest Twenty primitive.
- Disabled: same layout, lower contrast, no new decorative color.
- Loading: same dimensions as ready content, with skeleton blocks using the compact radius token.
## Cards And Panels
Use cards only for individual repeated items, notifications, popovers, or genuinely framed tools. Do not put cards inside cards.
Card defaults:
- Radius: `themeCssVariables.border.radius.md`.
- Padding: `themeCssVariables.spacing[2]` for compact cards.
- Title-to-body gap: `themeCssVariables.spacing[2]` only when the body is visually separate.
- Divider: `themeCssVariables.border.color.light` when sections need separation.
- Shadow: prefer `themeCssVariables.boxShadow.light` or the native host surface treatment. Do not combine a strong border with a strong shadow.
Overlay feedback pattern:
```tsx
const overlayFeedbackStyle = {
borderRadius: themeCssVariables.border.radius.md,
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[1]}`,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
background: themeCssVariables.background.overlayPrimary,
backdropFilter: `blur(${themeCssVariables.blur.medium})`,
boxShadow: themeCssVariables.boxShadow.strong,
};
```
Use overlay styling for small feedback surfaces only. Normal embedded widgets should usually use the host background, a subtle border, or no frame.
## Buttons And Actions
Use actions that feel like Twenty toolbar controls:
- Use `Button` for actual commands.
- Use Twenty button `size` and `variant` props before custom button styling.
- For custom compact controls, use `themeCssVariables.spacing[6]` as the control height token.
- Use `themeCssVariables.spacing[2]` for horizontal padding.
- Use `themeCssVariables.spacing[1]` for icon-label gaps.
- Use `themeCssVariables.border.radius.sm` for custom compact action radius.
- Use medium body text for labels.
- Use medium icons for primary actions and small icons for quiet chevrons.
- Use `themeCssVariables.spacing[6]` for icon-only action targets.
Applied example:
```tsx
<div
style={{
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.betweenSiblingsGap,
}}
>
<Button variant="secondary" size="small" title="Open record" />
<Button variant="secondary" size="small" title="Add note" />
</div>
```
## Tables And Dense Lists
Use table-like rhythm for CRM data:
- Header and row height: use `themeCssVariables.spacing[8]` for custom dense rows.
- Header text: medium body text with tertiary color.
- Cell text: regular body text with primary or secondary color.
- Cell padding: use `themeCssVariables.table.horizontalCellPadding`.
- Icon-label gap: use `themeCssVariables.spacing[1]`.
- Checkbox column: use `themeCssVariables.table.checkboxColumnWidth` when building table-like custom rows.
- First data column: make it the flexible `minmax(0, 1fr)` column so names get priority and actions stay aligned.
- Truncate long names with ellipsis. Do not wrap names in dense rows unless the row is intentionally two-line.
Applied example:
```tsx
const headerCellStyle = {
minHeight: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.table.horizontalCellPadding}`,
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[1],
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.tertiary,
};
```
## Empty, Loading, Error, Disabled, Success
Design the visible states before polishing the ready state:
- Loading: preserve final layout dimensions with skeletons that use the same row, text, and radius tokens as the ready content.
- Empty: use `Callout` or a compact empty block with one short title, one sentence, and one clear action.
- Error: use `Callout` with an error icon, explain what failed, and offer retry or recovery.
- Disabled: keep the same control size and placement. Lower contrast and explain the blocking condition if it is not obvious.
- Success: use a small status, toast-style card, or inline confirmation. Do not redesign the whole panel for success.
Loading example:
```tsx
const skeletonStyle = {
height: themeCssVariables.spacing[4],
width: '60%',
borderRadius: themeCssVariables.border.radius.sm,
background: themeCssVariables.background.transparent.light,
};
```
Empty example:
```tsx
<Callout
variant="secondary"
title="No activity yet"
description="Activity will appear here once this record has timeline events."
/>
```
## Text And Copy
Make text fit in real Twenty widths:
- Use sentence case.
- Use short labels.
- Use short button text.
- Use one-sentence descriptions.
- Put dynamic values in stable containers with ellipsis.
- Never let a record name, email, URL, or amount overlap an action.
- Prefer exact object names over generic labels. Use "Open company", not "Open item", when the object is known.
- Keep units and counts close to the value: `12 opportunities`, `$4,200`, `3 tasks`.
Text truncation pattern:
```tsx
const truncateStyle = {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
```
## Responsive Rules
Front components often render in narrow widgets:
- Design compact widths first.
- Use a single column by default.
- Move secondary actions into a menu before wrapping the whole toolbar.
- Keep the primary action visible when space is tight.
- Let content columns shrink with `minmax(0, 1fr)`.
- Use ellipsis for long identifiers instead of horizontal scroll in small widgets.
- Do not use hero-scale text, marketing sections, or wide decorative imagery in embedded CRM widgets.
Narrow layout pattern:
```tsx
const compactGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
};
```
## Quick Checks
Before considering a UI done, check this list:
- The component uses Twenty typography tokens, not custom text sizes.
- Cards use the medium radius token or a smaller existing primitive radius.
- Buttons and action controls use Twenty `Button` props or compact spacing tokens.
- Table/list rows use a consistent row-height token.
- Icons use `themeCssVariables.icon.size.*` or native Twenty icon sizing.
- Spacing uses `themeCssVariables.spacing[...]` and `themeCssVariables.betweenSiblingsGap`.
- Ready, loading, empty, error, and disabled states share the same structure.
- Important text has a stable left edge and truncates before it overlaps actions.
- Repeated rows have identical height, padding, icon size, action placement, and state behavior.
- The UI uses Twenty primitives and `themeCssVariables` before custom styling.
# Workflow
First, identify the target front component file and the user workflow it supports.
Design the visible states the user will naturally encounter: loading, empty, error, disabled, success, and the primary working state.
Then apply the rules in this order:
1. Set the information hierarchy: title, description, metadata, actions.
2. Lock the structural dimensions with Twenty primitives and `themeCssVariables`.
3. Align repeated edges and action positions.
4. Apply Twenty primitives and tokens.
5. Add hover, pressed, selected, loading, disabled, empty, error, and success states.
6. Test with long names, empty values, narrow widths, and multiple records.

View File

@@ -0,0 +1,114 @@
# App Structure
Use this reference when creating or modifying files inside an existing Twenty app.
Use `../manage-app/cli-and-sync.md` for CLI command behavior, remotes, authentication, sync troubleshooting, build, deploy, logs, and CI/CD.
## App Checks
Before changing app entities, confirm the current directory is a Twenty app:
```bash
test -f package.json
test -f src/application-config.ts
```
If this fails, do not edit from the wrong folder. Check the current path and nearby app roots:
```bash
pwd
find . -maxdepth 3 -name package.json -o -path '*/src/application-config.ts'
```
Move to the matching app root before continuing. If no app exists, use `create-app`. If the folder exists but tooling, dependencies, remotes, authentication, sync, or builds are broken, use `manage-app` before changing app entities.
Inspect the app shape:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
find src -maxdepth 3 -type f | sort
find public -maxdepth 2 -type f | sort
```
## Entity Creation
Prefer the app CLI for new entities when interactive prompts are acceptable:
```bash
yarn twenty dev:add
```
For non-interactive agent work, direct file creation is often better. Use generated CLI templates, local SDK typings, or existing app files as the source of truth for imports and config shape.
Use official Twenty docs or local SDK source when exact imports, entity fields, or configuration shapes matter.
## Source Layout
Add these alongside the scaffolded `src/{fields,objects,logic-functions,front-components,page-layouts,navigation-menu-items,constants}/` as needed:
- `src/utils/` — pure helpers as `<name>.util.ts`, kept flat.
- `src/types/` — one PascalCase type per file.
- `src/<service>-client/` — wrappers around external SDKs or HTTP clients. One folder per service, matching Twenty's `*-client` convention (`redis-client/`, `sdk-client/`, ...).
- Tests as `*.spec.ts` in sibling `__tests__/` folders next to the code they cover.
Filenames are kebab-case; conventional suffixes are `.util.ts`, `.dto.ts`, `.service.ts`, `.spec.ts` — matching the Twenty backend.
### One Export Per File
Every helper, type, and client file exports exactly one thing. The rule counts exports, not declarations — there are no exceptions for `utils/`, `types/`, or `<service>-client/`.
- Never put multiple function exports in the same file. One function per file, each with its own sibling spec.
- A file must never export more than one thing. A local (non-exported) type used only by the util may live in the same file, but the moment a type is exported or reused elsewhere it moves to `src/types/<name>.ts` while the util stays in `src/utils/<name>.util.ts`.
```ts
// ❌ Bad — src/utils/parse-company.util.ts exports a type and a util
export type ParsedCompany = { id: string; name: string };
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
// ✅ Good — src/utils/parse-company.util.ts (one export; the type is local)
type ParsedCompany = { id: string; name: string };
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
// ✅ Good — when the type is shared, split it
// src/types/parsed-company.ts (one PascalCase type)
export type ParsedCompany = { id: string; name: string };
// src/utils/parse-company.util.ts (one util)
import { type ParsedCompany } from 'src/types/parsed-company';
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
```
Entity files are consistent with this rule: front components, logic functions, and post-install hooks use a single `export default define...()`, which is one export.
Files inside `src/types/` use plain `<name>.ts` (one PascalCase type per file) — no `.type.ts` suffix. Files inside `src/<service>-client/` also use plain `<name>.ts`; the folder name carries the suffix, so do not repeat it on the file.
When field definitions differ only by `objectUniversalIdentifier` and label, replace them with a factory in `src/fields/field-factories.ts`.
Read secrets through the application-config helper, not raw `process.env`.
## Boundaries
- Do not scaffold a new app from this workflow. Use `create-app` first when the app does not exist.
- Do not guess generated entity shapes when the CLI or docs can provide them.
- Keep app changes scoped to the requested feature and its required registrations.
## Validation Checklist
Once all edits for the change are complete, run lint and typecheck once at the end (not after each individual edit), then sync the app to verify the definitions are valid:
```bash
yarn twenty dev:typecheck
yarn lint
yarn twenty apply
```
`yarn twenty dev:typecheck` checks generated app types and `yarn lint` checks local lint rules. `yarn twenty apply` then builds the app and pushes entity definitions to the active remote; if any definition is invalid, the sync reports the error. Run all three a single time once every edit is done, not repeatedly after each step.
When the user explicitly asks to run tests, follow `tests.md`: unit tests may use the package's unit-test script, and the full suite must run with `TWENTY_API_URL=http://localhost:2021` against the isolated test instance.
Switch to `manage-app` and use `../manage-app/cli-and-sync.md` for sync or remote troubleshooting.
Tests are not part of sync validation but should cover `src/utils/` helpers and post-install hook idempotency. See `tests.md`.

View File

@@ -0,0 +1,108 @@
# Data Model
Use this reference for Twenty app objects, fields, relations, roles, and permissions.
## Objects And Fields
Objects and fields define the records users will create, view, search, and automate. Model the user's workflow first, then add the smallest set of fields that makes the workflow usable.
When adding or modifying data model entities:
- Prefer generated app entity patterns from `yarn twenty dev:add`.
- Use clear object and field names that map to user-facing language.
- Add relations only when users need to navigate or report across records.
- Avoid duplicating data that already exists on core Twenty objects.
- Keep field types specific enough to support filtering, views, and automation.
### Usable Object Pattern
When the user asks for a new object and does not explicitly restrict the request to schema only, make the object usable in the app by pairing data model work with the minimum layout/navigation surfaces:
- Object file in `src/objects/<name>.ts`
- Table view in `src/views/all-<plural>.ts`
- Object navigation item in `src/navigation-menu-items/<name>.ts`
- Record page layout in `src/page-layouts/<name>-record-page-layout.ts`
- Fields-widget view for the record page in `src/views/<name>-record-page-fields.ts`
Use `layout.md` for view, navigation, and page layout details.
Objects support `icon`, but object color is not defined on `defineObject()`. Put color on the object navigation item:
```ts
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '<uuid>',
name: '<name>',
icon: '<IconName>',
color: '<color>',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: '<object-uuid>',
});
```
### Minimal Object Example
```ts
import { defineObject, FieldType } from 'twenty-sdk/define';
export const OBJECT_UNIVERSAL_IDENTIFIER = '<uuid>';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER = '<uuid>';
export default defineObject({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: '<name>',
namePlural: '<names>',
labelSingular: '<Name>',
labelPlural: '<Names>',
icon: '<IconName>',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconAbc',
},
],
});
```
### Select Fields
Select and multi-select option `value` strings must be uppercase snake case:
```ts
{
type: FieldType.SELECT,
name: 'status',
label: 'Status',
defaultValue: "'PLANNED'",
options: [
{ position: 0, label: 'Planned', value: 'PLANNED', color: 'sky' },
{ position: 1, label: 'In build', value: 'IN_BUILD', color: 'orange' },
],
}
```
For select fields, default values must be quoted string expressions like `"'PLANNED'"`, not plain strings like `'planned'`.
## Roles And Permissions
Roles should match operational responsibility, not implementation convenience.
When adding permissions:
- Grant the smallest useful scope.
- Keep sensitive objects and fields out of broad roles.
- Check whether the app introduces side effects through logic functions before granting write access.
## Verification
After data model changes, run the app and verify the objects, fields, and roles appear where the user will manage or use them.

View File

@@ -0,0 +1,120 @@
# Front Components
Use this reference for Twenty app front component source files, registration, data access, Twenty UI imports, runtime imports, and browser verification.
Use `layout.md` for placing a front component in a page layout. Use `standalone-pages.md` for full-page custom UI rendered through a front component widget. Use `../design/front-component-ui.md` for visual design, spacing, states, and polish. Use `app-structure.md` for the develop-app validation checklist and `../manage-app/cli-and-sync.md` for command behavior or troubleshooting.
## Source And Registration
By convention, keep front components under `src/front-components/`, often as `<name>.front-component.tsx`.
Register the component with `defineFrontComponent`:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
const MyFrontComponent = () => {
return <div />;
};
export default defineFrontComponent({
universalIdentifier: '<front-component-uuid>',
name: '<front-component-name>',
description: '<short description>',
component: MyFrontComponent,
});
```
Do not import or call `react-dom/client` in the component source. The SDK renderer mounts front components.
## Runtime Imports
Keep imports narrow:
- Use `twenty-sdk/front-component` for front component hooks and host APIs.
- Use `twenty-client-sdk/core` or `twenty-client-sdk/metadata` for data access.
- Install `twenty-ui` from npm at the same version as `twenty-sdk` and `twenty-client-sdk` and import Twenty UI components, icons, and theme tokens from its subpaths (`twenty-ui/primitives/input`, `twenty-ui/primitives/data-display`, `twenty-ui/icon`, `twenty-ui/primitives/typography`, `twenty-ui/theme-constants`, and others) before adding external UI libraries.
The front component renderer provides a Twenty `ThemeProvider` around the remote root. For isolated examples or local story-style verification, wrapping the component in `ThemeProvider` from `twenty-ui/theme-constants` is also acceptable.
The canonical Twenty UI front component example is `packages/twenty-front-component-renderer/src/__stories__/showcase/twenty-ui-example.front-component.tsx`. It imports `Button` from `twenty-ui/primitives/input`; `Chip`, `Status`, and `Tag` from `twenty-ui/primitives/data-display`; `Section` from `twenty-ui/components`; and `ThemeProvider` from `twenty-ui/theme-constants`.
Read theme tokens with the `useTheme()` hook from `twenty-ui/theme-constants` inside the component body. The same tokens are also exported as the `themeCssVariables` constant, but the SDK mocks `twenty-ui` during manifest extraction, so a module-level constant that dereferences `themeCssVariables` can fail before the component renders — prefer `useTheme()`.
Prefer Twenty UI icons from `twenty-ui/icon` when one exists. Use inline SVG only for app-specific marks or icons that are not available from `twenty-ui/icon`.
## Record Context And Data
For record-page components, read selection from front component context:
- Use `useSelectedRecordIds()` for single, bulk, or empty selection; derive one id only when the array length is 1.
Use the generated or core Twenty client for reads and writes. Keep loading, empty, error, disabled, and saving states explicit so runtime failures are visible and recoverable.
## Headless Actions And DRY Helpers
Headless front components should be thin action shells. The component file should mostly read SDK hooks, return the `Command` helper, and delegate reusable behavior to helpers.
Common headless action flow:
1. Read selected record IDs.
2. Load the selected records.
3. Build a payload for one logic-function call.
4. Execute the logic function.
5. Parse the result summary.
6. Show a snackbar.
7. Let `Command` unmount the component.
Do not duplicate this orchestration across sibling front components. When two components share command execution flow, extract it before copying:
- Pure helpers go in `src/utils/<name>.util.ts`.
- Front-component runtime helpers go in `src/front-components/utils/<name>.util.ts`.
- Payload builders, result parsers, selected-record validators, and summary formatters should be small testable functions with sibling specs.
Each extracted helper and type lives in its own file: one export per file. A local, non-exported type may stay with the util, but an exported type moves to its own file. See `app-structure.md`.
Prefer configuration-driven helpers when creating parallel actions for people, companies, tasks, or other objects. Each front component should provide only object-specific configuration, such as the front component universal identifier, logic function universal identifier, record query, payload builder, labels, and snackbar copy.
## Bulk Logic Function Calls
When a front component triggers a logic function for selected records, always prefer a bulk payload shape unless the user explicitly says the logic function is only for one record. The front component should call the logic function once with all selected records, not loop and execute the same logic function once per record.
Default payload shape:
```ts
{
records: Array<{
id: string;
// object-specific fields used by the logic function
}>;
}
```
Inside `records`, prefer `id` for the Twenty record ID because the array name already establishes record context. Do not add flat `recordId` payloads unless the user explicitly asks for a single-record action.
Logic functions that return per-record outcomes should mirror the same naming:
```ts
{
ok: boolean;
enrichedCount: number;
noMatchCount: number;
failedCount: number;
results: Array<{
id: string;
status: 'ENRICHED' | 'NO_MATCH' | 'FAILED';
error?: string;
}>;
}
```
If an existing logic function accepts only a flat record ID, prefer upgrading it to accept `records` and remove the old flat input unless the user explicitly asks to preserve it.
## Runtime Verification
A clean typecheck and sync is not runtime verification. After the standard validation in `app-structure.md`, open the relevant Twenty surface and confirm:
- The widget mounts without a `FrontComponent error` in the widget body or toast.
- The component renders loading, empty, and error states correctly.
- The main user action works against a real record.
- The page still renders after a hard refresh.

View File

@@ -0,0 +1,50 @@
# Layout
Use this reference for Twenty app views, navigation, page layouts, page layout tabs, and front component registration. Use `standalone-pages.md` when a page layout is meant to host a full-page custom UI.
## Views And Navigation
Views and navigation define the first-run experience. Add only the surfaces users need to understand and operate the app.
When changing views:
- Put the most common workflow first.
- Use concise names that match the data model.
- Keep list, board, and detail surfaces consistent with existing Twenty patterns.
- Avoid creating navigation entries for low-frequency admin tasks unless users need repeated access.
## Page Layouts
Page layouts should make the record's current state and next action easy to scan.
When adding layouts or tabs:
- Group related fields and components.
- Keep important record status visible without scrolling when possible.
- Place front components where they support the surrounding record context.
- Include empty and loading behavior for front components shown on record pages.
## Front Component Widgets
When adding an app-defined front component to a record page layout, use the component's universal identifier in the widget configuration:
```ts
{
universalIdentifier: '<widget-uuid>',
title: '<Widget title>',
type: 'FRONT_COMPONENT',
objectUniversalIdentifier: '<object-uuid>',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: '<front-component-uuid>',
},
}
```
Use `frontComponentUniversalIdentifier` for app-defined front components. A `frontComponentId` is not the same value and will not link the widget to the app component correctly.
## Verification
Run the app and inspect the user path from navigation to view to record detail. The route should be discoverable without relying on implementation knowledge.
For front component implementation and runtime verification after placement, use `front-components.md`.

View File

@@ -0,0 +1,111 @@
# Logic
Use this reference for Twenty app logic functions, skills, agents, and connection providers.
## Logic Functions
A logic function file should contain trigger registration, input validation, the call out to the work, and the writes back — nothing else. Everything else lives next to it (see `app-structure.md`):
- External API calls → `src/<service>-client/<name>.ts`.
- Response parsing and mapping → `src/utils/<name>.util.ts`.
- Response and DTO types → `src/types/<name>.ts`.
- Shared structure across object types → one mapper factory parameterized by object kind, not parallel functions.
Kebab-case filenames, one export per file. Response/DTO types go in `src/types/<name>.ts` and parsing/mapping helpers in `src/utils/<name>.util.ts` — never export both a type and a util from the same file (a local, non-exported type may stay with the util), and never put multiple function exports in one file. See `app-structure.md`.
Other rules:
- Validate required fields before writes or remote calls.
- Prefer bulk inputs for record actions. If a logic function can be triggered from selected records, the canonical input should be `records: Array<{ id: string; ...fields }>` unless the user explicitly says the function is only for one record.
- Use `id` inside a `records` array for the Twenty record ID. Do not add `recordId`, object-specific IDs such as `companyId`, or flat single-record payloads unless the user explicitly requests a single-record contract.
- Return a bulk summary with per-record results for multi-record actions, including counts for success, no match, and failed records.
- Prefer idempotent behavior for jobs and repeated invocations.
- Read secrets through the application-config helper, not raw `process.env`.
- Do not hide customer-impacting side effects behind UI-only actions.
Soft cap: a `*.logic-function.ts` or `*.post-install.ts` file over 200 lines is a refactor signal.
## Bulk Record Actions
Bulk is the default logic-function contract for actions that may run from front component selection. The front component should gather selected records and invoke the function once. Do not make the front component loop over selected records and execute the same logic function repeatedly unless there is an explicit single-record requirement.
Preferred input:
```ts
type BulkInput<TRecord extends { id: string }> = {
records: TRecord[];
};
```
Preferred output:
```ts
type BulkResult = {
ok: boolean;
enrichedCount: number;
noMatchCount: number;
failedCount: number;
results: Array<{
id: string;
status: 'ENRICHED' | 'NO_MATCH' | 'FAILED';
pdlId?: string;
error?: string;
}>;
};
```
For existing single-record functions that are being upgraded to selected-record actions, replace the old flat input with `records: Array<{ id: string; ...fields }>` unless the user explicitly asks for backward compatibility.
Extract and test:
- input normalization for the canonical bulk shape;
- per-record validation;
- external API payload mapping;
- per-record result mapping;
- summary count aggregation;
- error message normalization.
## Skills And Agents
Skills and agents should describe when they apply, what context they need, and what output is expected.
When adding AI behavior:
- Make trigger rules concrete.
- Keep instructions grounded in available app data and tools.
- State when the agent should ask for missing workspace or record context.
- Avoid exposing raw IDs, timestamps, or nested API output to end users when a readable answer is possible.
## Connection Providers
For third-party connections:
- Keep secrets out of source and public assets.
- Document required OAuth or API setup in the app README or listing.
- Verify failure states for expired or missing credentials.
## Post-Install Hooks
Use `definePostInstallLogicFunction` for records that must exist on install — default workflows, views, roles, or seeded reference data. Do not implement this as runtime first-run code.
Post-install hook files live alongside other logic functions (typically `src/logic-functions/<name>.post-install.ts`). Kebab-case filename, one export per file.
Hooks must be idempotent: find by stable identifier before creating, update if it exists, never duplicate. Treat a not-found from a single-record query as "needs create."
Do not write fields Twenty computes elsewhere (workflow `statuses` is computed from version status — see `workflows.md`).
Dev sync skips install hooks. Invoke locally:
```bash
yarn twenty dev:function:exec
```
Run again after rebuilding to verify idempotency.
## Uninstall Hook
Use `defineUninstallLogicFunction` for best-effort cleanup of external resources when the app is uninstalled (deprovision API resources, delete remaining bots, revoke webhooks). Failures are logged and never block the uninstall.
Uninstall hook files live alongside other logic functions (typically `src/logic-functions/uninstall.ts`). Kebab-case filename, one export per file.
The hook runs before the app's metadata, data, and code are removed, so handlers can still query the app's objects and records. Handlers receive `UninstallPayload` (`{ version?: string }`).

View File

@@ -0,0 +1,461 @@
# Standalone Pages
Use this reference when a Twenty app needs a full-page custom UI: an operational console, map, canvas, planner, status wall, or other page-sized tool.
For general page layout and navigation entities, use `layout.md`. For front component source, runtime imports, hooks, data access, and browser verification, use `front-components.md`. For visual polish and Twenty UI component choices, use `../design/front-component-ui.md`. For CLI and deployment command details, use `../manage-app/cli-and-sync.md`.
This reference owns only the standalone-page assembly pattern and full-page behavior. It repeats small code fragments where they are needed to show how the pieces connect, but leaves general API behavior to the linked references.
## Mental Model
A standalone page is not a raw page body component. In the current local app pattern, custom page content should be rendered through a `FRONT_COMPONENT` widget inside a `STANDALONE_PAGE` page layout. There does not appear to be a separate public "page body component" API for app-defined standalone pages.
The current model is:
1. Define stable universal identifiers.
2. Register the page experience with `defineFrontComponent`.
3. Place that front component in a `definePageLayout` with `type: 'STANDALONE_PAGE'`.
4. Surface the page with `defineNavigationMenuItem` using `NavigationMenuItemType.PAGE_LAYOUT`.
5. Sync or install the app.
6. Twenty resolves the sidebar item to the `/page/:pageLayoutId` route and renders the front component widget inside the page layout.
Use these surfaces for different jobs:
| Surface | Use it for | Primary app entity |
| --- | --- | --- |
| Standalone page | A full workspace page that is not tied to one record | `STANDALONE_PAGE` + `PAGE_LAYOUT` navigation item + `FRONT_COMPONENT` widget |
| Record page layout | Tabs and widgets for one object record | `RECORD_PAGE` page layout or page layout tab |
| Dashboard | Metric and report composition from built-in widgets | `DASHBOARD` page layout |
| Command or side panel | Short actions, focused forms, one selected record, or background commands | `defineFrontComponent` plus command menu item |
## Removing The Scaffolded Placeholder
Every freshly scaffolded app contains three placeholder files that wire a "Welcome" sidebar item to a generic landing page:
- `src/front-components/main-page.tsx`
- `src/page-layouts/main-page.page-layout.ts`
- `src/navigation-menu-items/main-page.navigation-menu-item.ts`
If the app has no user-facing page — for example, it only extends standard objects, declares logic functions, or seeds workflows — delete all three files before the first deploy. Leaving them in ships a dead sidebar item.
## Quickstart
Use this minimal file set:
- `src/constants/universal-identifiers.ts`
- `src/front-components/<name>.front-component.tsx`
- `src/page-layouts/<name>.page-layout.ts`
- `src/navigation-menu-items/<name>.navigation-menu-item.ts`
Start with a tiny component that proves the route works before building the full page.
```ts src/constants/universal-identifiers.ts
export const MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a0a1c4f0-f23a-4c59-93c5-92146d64b110';
export const MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'63d4970d-6c54-49c8-9c15-52d7cb00fb7a';
```
```tsx src/front-components/mission-control.front-component.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
minHeight: '100%',
padding: 24,
placeItems: 'center',
width: '100%',
}}
>
<h1 style={{ margin: 0 }}>Mission Control</h1>
</main>
);
};
export default defineFrontComponent({
universalIdentifier: MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'mission-control',
description: 'Standalone mission control page.',
component: MissionControl,
});
```
```ts src/page-layouts/mission-control.page-layout.ts
import {
definePageLayout,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Mission Control',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'e6963ad3-e5fa-41c7-83e1-4fc2ca5de9a8',
title: 'Mission Control',
position: 0,
icon: 'IconRocket',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '18ce05bb-ee3c-4332-80a7-f8fb84f7f70a',
title: 'Mission Control',
type: 'FRONT_COMPONENT',
heightBehavior: 'TAB_VIEWPORT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
```ts src/navigation-menu-items/mission-control.navigation-menu-item.ts
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: '61d44a63-16e8-4fbe-bccb-9c220d44fdb9',
name: 'Mission Control',
icon: 'IconRocket',
color: 'blue',
position: 50,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
```
Use a `VERTICAL_LIST` tab and give its single widget `TAB_VIEWPORT` height behavior for a page-sized surface. The widget owns internal scrolling while the tab owns its viewport.
After the tiny page renders, replace the component body with a full-screen structure:
```tsx
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
gap: 16,
gridTemplateRows: 'auto minmax(0, 1fr)',
height: '100%',
minHeight: '100%',
overflow: 'hidden',
padding: 16,
width: '100%',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>Mission Control</h1>
<button type="button">Refresh</button>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: '280px minmax(0, 1fr)',
minHeight: 0,
overflow: 'hidden',
}}
>
<aside style={{ minHeight: 0, overflow: 'auto' }}>Filters</aside>
<div style={{ minHeight: 0, overflow: 'auto' }}>Workspace data</div>
</section>
</main>
);
};
```
## API Reference
This section only calls out the fields that matter for standalone pages. Use `layout.md` and `front-components.md` for broader entity guidance.
`definePageLayout` owns the standalone route target:
- Use `type: 'STANDALONE_PAGE'`.
- Do not set `objectUniversalIdentifier`; standalone pages are not record scoped.
- Define at least one tab. Use one tab unless the page needs real top-level modes.
- Use `PageLayoutTabLayoutMode.VERTICAL_LIST`; widgets inherit the tab's layout mode and follow their order in the `widgets` array. Put the `FRONT_COMPONENT` last and set its top-level `heightBehavior` to `'TAB_VIEWPORT'`.
- Use a `FRONT_COMPONENT` widget with `configurationType: 'FRONT_COMPONENT'` and `frontComponentUniversalIdentifier`.
`defineFrontComponent` owns the actual page experience:
- Register a visible component with `component`, `name`, `description`, and a stable `universalIdentifier`.
- Use `twenty-sdk/front-component` for runtime hooks, host navigation, snackbars, application variables, and side panel actions.
- Use `twenty-client-sdk/core` for workspace records when the page is data driven.
- Use `getPublicAssetUrl` from `twenty-sdk/define` for app-bundled images or static files.
`defineNavigationMenuItem` owns sidebar reachability:
- Use `type: NavigationMenuItemType.PAGE_LAYOUT`.
- Set `pageLayoutUniversalIdentifier` to the standalone page layout universal identifier.
- Set a clear `name`, `icon`, `color`, and `position`.
- Use `folderUniversalIdentifier` only when the page belongs under an existing app folder.
Public assets and non-secret application variables make standalone pages richer without hardcoding environment data:
```tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const logoUrl = getPublicAssetUrl('mission-control-logo.png');
const MissionBrand = () => {
const label = getApplicationVariable('MISSION_LABEL') ?? 'Mission Control';
return <img src={logoUrl} alt={label} />;
};
```
## Full-Page Layout Guidance
The front component only fills the page if every layer inside the widget has deterministic sizing. Start the root at `height: '100%'`, `minHeight: '100%'`, `width: '100%'`, and `boxSizing: 'border-box'`.
Use `minmax(0, 1fr)` and `minHeight: 0` for scrollable grid or flex children. Without these constraints, inner tables, maps, and canvases can force the page taller than the widget or collapse into a zero-height area.
Prefer one immersive tool surface over a dashboard made of many small cards. If the page is a mission tracker, map, editor, kanban, planner, or cockpit, make the front component own the composition and use internal panels only where they support the workflow.
Assume the available area changes with Twenty chrome, the left sidebar, side panel state, tab list, and smaller screens. Use responsive CSS inside the front component:
- Collapse side filters above or below the main surface on narrow widths.
- Keep map, canvas, table, and timeline containers at `minHeight: 0`.
- Make only intentional regions scroll.
- Keep loading, empty, and error states inside the same sized root so the page never goes blank while data changes.
Avoid hidden overflow traps. `overflow: 'hidden'` is useful for map and canvas roots, but pair it with explicit scroll containers for lists and diagnostics.
## Data And Interactivity
Fetch live workspace records from the front component when the page reflects workspace state:
Use `front-components.md` for general client/runtime rules. In standalone pages, the key additions are visible full-page loading, empty, and error states, plus navigation from the standalone surface back into Twenty records.
```tsx
import { useEffect, useState } from 'react';
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppPath,
enqueueSnackbar,
navigate,
} from 'twenty-sdk/front-component';
type CompanySummary = Pick<CoreSchema.Company, 'id' | 'name'>;
const CompaniesStandalonePage = () => {
const [companies, setCompanies] = useState<CompanySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadCompanies = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const result = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
},
},
},
});
if (!cancelled) {
setCompanies(result.companies.edges.map((edge) => edge.node));
}
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load companies';
if (!cancelled) {
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadCompanies();
return () => {
cancelled = true;
};
}, []);
if (loading) return <div style={{ padding: 24 }}>Loading companies...</div>;
if (error) return <div style={{ padding: 24 }}>{error}</div>;
if (companies.length === 0) {
return <div style={{ padding: 24 }}>No companies yet.</div>;
}
return (
<div style={{ display: 'grid', gap: 8, padding: 24 }}>
{companies.map((company) => (
<button
key={company.id}
type="button"
onClick={() =>
navigate(AppPath.RecordShowPage, {
objectNameSingular: 'company',
objectRecordId: company.id,
})
}
>
{company.name}
</button>
))}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '5aa8c51f-72a4-4929-a6ab-2d4ea9d2a6df',
name: 'companies-standalone-page',
description: 'Lists companies and opens related records.',
component: CompaniesStandalonePage,
});
```
Always model these states:
- Loading: show progress in the same page shell that the loaded view uses.
- Empty: explain what is missing and offer the next action if one exists.
- Error: show the message and keep a retry path visible.
- Auth refresh: token failures can happen during development or long-lived sessions; surface the failure instead of returning `null`.
- Demo data: label fake data clearly and keep the switch to live data obvious.
Use `navigate` for full-page routes and `openSidePanelPage` for focused side-panel workflows. Prefer navigating to the record route when the user is leaving the standalone experience to inspect a real record.
## Debugging
For a black screen, check the simplest causes first:
- Component runtime exception: open the browser console and look for a front component error, React error, or Remote DOM unsupported operation.
- Missing data fallback: temporarily replace the component with the tiny "Mission Control" component from this reference.
- Token or fetch failure: log the caught error, confirm `CoreApiClient` generation, and show an error state.
- Public asset failure: verify `getPublicAssetUrl(...)` output in the network tab and render without the asset.
- CSS layer covering content: remove absolute overlays, `zIndex`, and full-screen backgrounds until text is visible.
- Zero-height container: add visible borders and confirm `height: '100%'`, `minHeight: '100%'`, `minHeight: 0`, and the widget's `TAB_VIEWPORT` position.
- Unsupported browser or Remote DOM behavior: remove unusual DOM APIs, portals, global document access, and third-party components until the minimal UI renders.
- Stale deployed app version: confirm the installed app version is the one you just synced or deployed.
Console checks:
```tsx
console.info('Standalone page mounted');
console.info('Companies loaded', companies.length);
```
Installed-app checks:
- Confirm the app appears in Twenty settings or the application developer view.
- Confirm the sidebar item appears with the expected icon and label.
- Confirm clicking the sidebar item opens `/page/:pageLayoutId`.
- Confirm the page still renders after a hard refresh.
Known-good component test:
```tsx
const KnownGoodStandalonePage = () => (
<main style={{ minHeight: '100%', padding: 24 }}>
<h1>Standalone page runtime is working</h1>
</main>
);
```
If this renders but the full page does not, the issue is inside the full component. If this does not render, inspect the layout, navigation, sync, installation, and front component registration.
## Deployment And Verification
Use bounded apply for deployment and verification. Use `../manage-app/cli-and-sync.md` for exact command behavior, remote setup, verbose troubleshooting, deploys, and logs.
```bash
yarn twenty apply
```
Install and update flow:
- During development, sync against the active remote and confirm the app appears as installed in the target workspace.
- For an already installed app, sync or deploy the updated version, then reopen the sidebar route and hard refresh the page.
- For packaged deploys, bump `package.json` before publishing an update to a workspace that already has the app installed.
Versioning expectations:
- Dev sync updates the active remote during development.
- Deploying a packaged update requires a strictly higher `package.json` version than the installed version.
- Users may need to update or reinstall the app depending on how the target workspace receives application updates.
Acceptance checks:
- Sidebar item appears in the expected section or folder.
- Sidebar item opens the standalone page route.
- The front component renders visible content.
- The component fills the widget/page area at desktop size.
- Data loads from live workspace records or a clearly labeled demo source.
- Loading, empty, and error fallbacks are visible and nonblank.
- Record navigation or side panel interactions work.
- Desktop and smaller-screen screenshots are nonblank and do not show overlapping controls.
## Examples
Minimal standalone page:
- One front component.
- One `STANDALONE_PAGE` page layout.
- One `PAGE_LAYOUT` navigation item.
- One `FRONT_COMPONENT` widget with `TAB_VIEWPORT` height behavior.
Full-screen operational page:
- Root grid with header and `minmax(0, 1fr)` body.
- Left filter panel, central work surface, optional right inspector.
- Loading, empty, error, and retry UI inside the same shell.
Data-driven page that opens related records:
- Fetch records with `CoreApiClient`.
- Render a list, table, timeline, or map.
- Use `navigate(AppPath.RecordShowPage, { objectNameSingular, objectRecordId })` for record drill-in.
Immersive canvas or map-style page, such as a Space X Mission Tracking page:
- Keep the map/canvas as the main full-height surface.
- Put filters, mission status, and selected mission details in internal panels.
- Use public assets for mission patches or map overlays.
- Avoid composing the page as dashboard cards unless the user is primarily comparing metrics.

View File

@@ -0,0 +1,46 @@
# Tests
Tests use the `*.spec.ts` extension and live in sibling `__tests__/` folders next to the code they cover, matching Twenty backend conventions. Write them where `yarn twenty apply` does not validate correctness.
Always write a test file for every util or function. Whenever you create or modify a file in `src/utils/` (or any other testable function file), you MUST create or update its sibling `__tests__/<name>.spec.ts`. A util or function without a spec file is incomplete.
## File Organization
Write one spec file per util or function source file. Each testable source file gets a dedicated sibling `__tests__/<name>.spec.ts` whose name mirrors the source file. Do not combine multiple utils or functions into a single spec file.
```
src/utils/parse-foo.util.ts
src/utils/__tests__/parse-foo.util.spec.ts
src/utils/map-bar.util.ts
src/utils/__tests__/map-bar.util.spec.ts
```
## What To Test
- Parsers in `src/utils/` — every branch of foreign API shape handling, including missing fields and empty responses.
- Mappers in `src/utils/` — foreign-to-Twenty mapping, including the "no match" case.
- Front-component helpers in `src/front-components/utils/` — selected-record validation, payload builders, logic-function result parsing, summary aggregation, and snackbar message formatting.
- Bulk logic-function normalization — the canonical `records: Array<{ id: string; ...fields }>` shape, including empty arrays and records missing `id`.
- Post-install hooks — at minimum, idempotency: run twice, assert state is identical.
- Side effects (billing, external state) — assert they fire only on the conditions the code claims.
## What To Skip
- Entity definitions (`*.field.ts`, `*.object.ts`) — sync validates these.
- Front component rendering shells — verify in the browser. Extracted helper functions are not rendering shells and must be unit-tested.
## Running Tests
Integration tests build, deploy, install, and uninstall the app on whatever server `TWENTY_API_URL` points to. The scaffold defaults to the dev instance (`http://localhost:2020`), so running them there adds and then removes the app from the workspace you sync to with `yarn twenty apply`. Always run them against the isolated test instance instead — a separate container, database, and port (`2021`) that does not affect your running dev instance:
```bash
# Start the isolated test instance (once).
yarn twenty docker:start --test
# Run integration tests against it.
TWENTY_API_URL=http://localhost:2021 yarn test
```
The seeded default `TWENTY_API_KEY` works for both instances, so only the URL needs overriding. This mirrors CI, which spawns the same isolated instance via the `spawn-twenty-app-dev-test` action.
When the user asks you to run tests, do run them. First start or verify the isolated test instance, then run the test command with `TWENTY_API_URL=http://localhost:2021`. Do not run integration tests against `http://localhost:2020` unless the user explicitly asks to target the dev instance. If only unit tests are requested, use the package's unit-test script and no `TWENTY_API_URL` override is needed.

View File

@@ -0,0 +1,47 @@
# Workflows
Use when the app needs to ship a manual workflow on install, or when a logic function should be invocable from the workflow builder.
See `logic.md` for post-install hooks. See `app-structure.md` for source layout.
## Mental Model
Twenty workflows are workspace records, not app entities — no `define*` primitive. Ship them via `definePostInstallLogicFunction` and the workspace API.
A workflow is a `Workflow` plus at least one `WorkflowVersion`. Creating a `Workflow` auto-creates its draft `v1`; never create a `WorkflowVersion` directly.
## Manual Record Trigger
- Trigger type: manual record selection.
- The selected record is the trigger payload — no wrapping `record` field.
- Reference fields with `{{trigger.<field>}}`. Do not use `{{trigger.record.<field>}}`.
## Lifecycle
Always: create → configure draft → activate. Never publish a draft directly.
1. Find or create the `Workflow` by stable name or slug. `createWorkflow` produces the draft `v1`.
2. Set the trigger on the draft via `updateWorkflowVersion`.
3. Add steps via the workflow-step mutations. `createWorkflowVersionStep` returns a `stepsDiff`, not the step — read the diff for the new step id, then call `updateWorkflowVersionStep` with the full payload.
4. Activate via `activateWorkflowVersion`.
Forbidden:
- Do not write `Workflow.statuses` — it is computed from the active version's status.
- Do not create `WorkflowVersion` rows directly.
## Idempotency
Find by deterministic name or slug before creating. Single-record queries throw on absence in some Twenty versions — treat not-found as "needs create."
## Permissions
Seeders need workflow settings permissions on the app role. Grant via `defineRole`. If a typed mutation is rejected from the app context, fix the role — falling back to raw GraphQL signals the wrong scope.
## Invoking Locally
```bash
yarn twenty dev:function:exec
```
`yarn twenty apply` skips install hooks. Run it again after rebuilding to verify idempotency.

View File

@@ -0,0 +1,172 @@
# CLI And Sync
Use this reference for Twenty app CLI command behavior, remotes, authentication, sync, validation commands, build, deploy, logs, function execution, and CI/CD troubleshooting.
Use `../develop-app/app-structure.md` only for app file layout and post-entity-edit validation checklists.
## App Checks
Before running operational commands, confirm the current directory is a Twenty app and inspect the active scripts and remotes:
```bash
test -f package.json
test -f src/application-config.ts
sed -n '1,220p' package.json
yarn twenty remote:list
```
Treat deploys, uninstalls, production remote changes, and production syncs as externally visible actions. Ask for explicit confirmation before running them when the target is production or user data could be affected.
## Validation Commands
Use these commands to validate an app after changes:
```bash
yarn twenty dev:typecheck
yarn lint
```
If the app is already installed on the active remote, preview and then apply
the metadata changes:
```bash
yarn twenty plan
yarn twenty apply
```
`yarn twenty dev:typecheck` checks generated app types and TypeScript compatibility. `yarn lint` checks local lint rules. `yarn twenty plan` previews metadata changes, and `yarn twenty apply` performs a bounded build/sync against the active remote.
`yarn twenty plan` is read-only and requires the app to already be installed
on the active remote. For a first sync, run `yarn twenty apply`; it registers
the app before synchronizing it. `create-twenty-app` normally performs this
initial sync, so a successfully scaffolded app does not need an extra apply.
Use the first-sync path when the scaffold sync failed, or when the app was
created or copied without being installed on this remote.
If a validation command fails because of entity definitions, switch to `develop-app` for the fix. If it fails because of remotes, authentication, build tooling, sync, logs, deploys, or CI/CD, stay in `manage-app`.
## Remotes
A remote is a Twenty server that the app can sync or deploy to. Credentials are stored locally in `~/.twenty/config.json`.
Common commands:
```bash
# Add a remote interactively.
yarn twenty remote:add
# Connect to a local Twenty server.
yarn twenty remote:add --local
# Add a remote non-interactively.
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as production
# List configured remotes.
yarn twenty remote:list
# Switch the active remote.
yarn twenty remote:use <name>
```
When the user says "prod", "production", or "workspace de prod", identify the target remote before syncing or deploying. If it is missing, add it with `--as production` or the user-provided name.
## Development Sync
Use the bounded apply command to synchronize app changes with the active remote:
```bash
yarn twenty apply
```
Do not use bare `yarn twenty dev` (watch mode). Run `yarn twenty apply` each time changes need to be synced.
Apply requires an authenticated remote. If authentication fails, re-add or switch the remote before retrying.
Use verbose apply for bounded troubleshooting:
```bash
yarn twenty apply --verbose
```
## Troubleshooting
Start by identifying which layer is failing:
- Remote/authentication.
- Dev sync or apply.
- Build/package generation.
- Deploy/upload/install.
- Runtime function execution.
- CI/CD environment or secrets.
Collect the minimum useful context before changing configuration:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
yarn twenty remote:list
yarn twenty apply --verbose
```
For remote or authentication issues:
- Check that the active remote is the intended workspace or server.
- Re-run `yarn twenty remote:add --local` for local app-dev servers.
- Re-run `yarn twenty remote:add --url <url> --as <name>` for remote servers.
- Avoid overwriting a production remote until the target URL is confirmed.
For sync issues:
- Prefer `yarn twenty apply --verbose` to get a bounded failure.
- Check generated type or schema errors before editing app entities.
- If the app depends on a changed data model, use `develop-app` to fix the entity definitions.
## Build, Deploy, Logs, And Exec
Build before deploy when the user asks for release readiness or when debugging packaging issues:
```bash
yarn twenty dev:build
```
Publish an app to npm, or publish privately to a configured Twenty server registry:
```bash
yarn twenty app:publish
yarn twenty app:publish --private --remote production
yarn twenty app:install --remote production
```
Before deploying an update, check that `package.json` has a strictly higher semver `version` than the currently deployed version. Re-deploying the same version is rejected. After a private publish, install the deployed version with `yarn twenty app:install` when the target workspace should use it.
Use function logs when debugging runtime behavior on the connected server:
```bash
yarn twenty dev:function:logs
yarn twenty dev:function:logs -n <function-name>
```
Run a logic function manually when testing behavior without its trigger:
```bash
yarn twenty dev:function:exec -n <function-name>
yarn twenty dev:function:exec -n <function-name> -p '{"key":"value"}'
```
Uninstall only after confirmation:
```bash
yarn twenty app:uninstall
yarn twenty app:uninstall --yes
```
Integration tests install and uninstall the app on their target server, so run them against the isolated test instance (`yarn twenty docker:start --test`, port `2021`) rather than the dev instance — see `../develop-app/tests.md`.
## CI/CD
Apps generated with `create-twenty-app` can use GitHub Actions for CI and CD. For deployment automation, check the app's `.github/workflows/` files and configure:
- `TWENTY_DEPLOY_URL`
- `TWENTY_DEPLOY_API_KEY`
Do not put API keys in source files. Use the repository or CI secret store.

View File

@@ -0,0 +1,212 @@
# Prepare App Listing
Use this reference for the publish-facing documentation and visuals of a Twenty app. It complements `create-app`; read this when the app is ready for packaging, marketplace listing, npm publishing, private sharing, or user-facing review.
## When To Use
Use this when the user asks to:
- Write or improve a Twenty app `README.md`.
- Prepare npm package content or Twenty marketplace copy.
- Add or audit `defineApplication()` marketplace metadata.
- Create, select, capture, or validate app logo and screenshots.
- Decide what belongs in `public/` for marketplace, front components, or logic functions.
- Make the app understandable to admins, workspace members, and reviewers before publish/deploy.
## Source Rules
Twenty marketplace metadata comes from `defineApplication()`:
- `displayName`
- `description`
- `author`
- `category`
- `logoUrl`
- `screenshots`
- `aboutDescription`
- `websiteUrl`
- `termsUrl`
- `emailSupport`
- `issueReportUrl`
`logoUrl` and `screenshots` must reference files from the app `public/` folder, for example `public/logo.png` and `public/screenshot-1.png`. If `aboutDescription` is omitted, the marketplace uses the package `README.md` from npm as the About tab content.
Files in `public/` are public, synced in dev mode, included in builds, and served without authentication. Never put secrets, private data, customer records, real tokens, or unreleased confidential material in public assets.
## README Workflow
Before writing, inspect the app:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
find src -maxdepth 3 -type f | sort
find public -maxdepth 2 -type f | sort
```
Read app entities to understand the actual product surface:
- Objects and fields define the data model users will see.
- Views, navigation, and page layouts define the first-run experience.
- Logic functions explain automation and side effects.
- Front components explain UI surfaces and interactions.
- Skills and agents explain AI behavior.
- Connection providers explain third-party OAuth setup.
- Roles explain permission scope and data access.
Write the README for the person installing or evaluating the app, not for the original implementer.
## README Shape
Use this structure unless the app already has a stronger local convention:
```markdown
# App Display Name
One-sentence summary of what the app adds to Twenty.
## What It Does
- Concrete user-facing capability.
- Concrete user-facing capability.
- Concrete user-facing capability.
## What It Adds To Twenty
- Objects:
- Fields:
- Views and navigation:
- Page layouts:
- Front components:
- Logic functions:
- Skills and agents:
- Connections:
## Requirements
- Twenty server/version requirement, if any.
- Third-party account or OAuth app requirements, if any.
- Required server variables or application variables.
## Setup
1. Install or deploy the app.
2. Configure required variables.
3. Add any required connections.
4. Open the relevant Twenty view or app settings page.
## Usage
Short workflow with concrete user actions and expected result.
## Development
Commands for local setup, dev sync, tests, build, deploy, and publish.
## Permissions And Data
What the app reads, writes, updates, deletes, and sends to third-party services.
## Support
Support email, issue tracker, docs, terms, or website links.
```
Keep the README accurate and specific. Do not claim the app supports an entity, workflow, permission, integration, or marketplace capability unless it exists in code.
## Copy Guidelines
Use plain product language:
- Lead with the workspace outcome, not the implementation.
- Name the Twenty objects and views users will actually interact with.
- Explain setup variables by their exact names.
- Distinguish server admin setup from workspace member usage.
- For OAuth connections, mention the provider redirect URI: `<SERVER_URL>/auth/apps/callback`.
- Document permissions in terms of user risk: read, create, update, soft delete, destroy, third-party send.
Avoid:
- Marketing filler.
- Claims about security, compliance, uptime, or certification that are not backed by the app.
- Screenshots or examples containing real customer data.
- API keys, OAuth secrets, tokens, or private server URLs.
## Visual Assets
Put marketplace and runtime visuals in `public/`:
```text
public/
logo.png
screenshot-1.png
screenshot-2.png
screenshot-3.png
```
Use stable, descriptive filenames. Prefer PNG for marketplace visuals. Keep source files only if the project already keeps editable artwork.
Logo guidance:
- Make it legible at small sizes.
- Use simple geometry or product-specific symbolism.
- Avoid text-heavy logos unless the brand requires it.
- Do not use the Twenty logo as the app logo unless the app is officially owned by Twenty and the user intends that.
Screenshot guidance:
- Capture real app surfaces after `yarn twenty apply` or watch-mode sync.
- Prefer the most useful user paths: app listing/about page, object index view, record page layout, front component surface, connection settings, or AI skill/agent behavior.
- Use seeded or synthetic data only.
- Hide browser chrome unless it helps explain setup.
- Do not fake product screenshots with generated images. Use generated visuals for logos, diagrams, empty-state art, or concept assets only.
When a visual is generated or edited, store the final bitmap in `public/` and reference it from `defineApplication()`. If the image appears in README markdown, include useful alt text.
## Application Metadata
Update `src/application-config.ts` with marketplace metadata when publishing:
```ts
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
author: 'Your Company',
category: 'Productivity',
logoUrl: 'public/logo.png',
screenshots: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
websiteUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
emailSupport: 'support@example.com',
issueReportUrl: 'https://github.com/org/app/issues',
});
```
Only set `aboutDescription` when the marketplace About tab should differ from the npm README. Otherwise keep one source of truth in `README.md`. The About tab renders markdown text only: images and raw HTML (badges, `<div>` blocks) are not rendered, so keep the marketplace-facing content in plain markdown.
If the app requires a specific Twenty server version, set `engines.twenty` in `package.json`. If publishing to npm, add the `twenty-app` keyword.
## Validation
Before considering README and visuals done:
```bash
yarn twenty apply
yarn twenty dev:build
```
Then check:
- Every `logoUrl` and `screenshots` path exists in `public/`.
- `README.md` setup steps match actual variables, connections, and commands.
- `package.json` has `keywords: ["twenty-app"]` when the app is intended for npm marketplace publishing.
- `defineApplication()` metadata does not reference placeholder URLs, support addresses, screenshots, or terms.
- Screenshots are current, readable, and free of secrets or real personal data.
- Public assets render where they are used by front components, logic functions, README, or marketplace metadata.
If visual verification matters, run the app in a browser and capture fresh screenshots instead of relying on stale files.

View File

@@ -0,0 +1,186 @@
# Retrieve Workspace Data
## Overview
Retrieve the Twenty records needed to answer the user's question, then present them as a useful answer, not as raw API output. Always translate technical fields, timestamps, IDs, and nested structures into readable summaries that help the user scan, compare, and act.
## Retrieval Workflow
Resolve the intended workspace before selecting a Twenty MCP server:
- If the user names a workspace, host, MCP server, or URL, use only the matching Twenty MCP namespace or server.
- If multiple Twenty MCP namespaces or configured servers are available and the intended workspace is ambiguous, ask one concise clarifying question before retrieving data.
- If exactly one Twenty MCP namespace is available and the user did not specify a workspace, use it and mention which workspace or server was used when reporting results.
- If a configured server exists but the matching MCP tools are not visible in the current thread, use the MCP setup troubleshooting workflow in `setup.md` instead of falling back to a different workspace.
- Before querying workspace data, confirm the callable Twenty MCP namespace or server name corresponds to the intended workspace whenever there is any ambiguity.
Use the selected connected Twenty MCP server when it is available:
```text
learn_tools -> execute_tool
```
- Discover object, field, filter, and sort names before querying.
- Retrieve only answer, ordering, and disambiguation fields.
- For "latest", "most recent", or "recent" requests, show the timestamp used for sorting.
- Limit broad lists and state how many records are shown.
- Ask one clarifying question only when tools cannot supply required context.
- If no Twenty MCP tools are available, use the setup workflow in `setup.md`; do not invent workspace data.
## Workspace Origin
Record links need a workspace origin, such as `https://example.twenty.com` or `http://workspace.localhost:3001`.
- If the user provides the workspace URL, use that origin after removing any trailing `/mcp`.
- If the selected MCP server URL is visible, derive the origin from it by removing the trailing `/mcp`.
- If the selected MCP server is configured locally but the URL is not in context, inspect that exact server configuration before formatting linked records.
- If the origin is still unknown, do not invent a hostname. Explain that direct record links need the workspace URL.
## Response Shape
Start with the answer or count, then show the records in the clearest compact shape:
- For one record, use a short labelled summary.
- For two to ten comparable records, use a Markdown table.
- For larger sets, show the most relevant rows first, mention the total, and offer the next useful filter or page only when needed.
- For nested records, summarize the important nested values instead of dumping JSON.
- When comparing records across workspaces, prefer one combined table with a `Workspace` column if it improves scanning. Use separate sections only when each workspace needs different columns.
Use English labels and prose. Keep user-provided names, record values, emails, URLs, and proper nouns unchanged.
## Record Links
Link records back to their original Twenty context whenever the workspace origin and record identity are known.
- Build record links with the Twenty show-page path: `/object/:objectNameSingular/:objectRecordId`.
- For absolute links, combine the workspace origin with that path: `{workspaceOrigin}/object/{objectNameSingular}/{recordId}`.
- Preserve the workspace scheme and port for local workspaces, for example `http://workspace.localhost:3001/object/person/record-id`.
- Use `recordReferences` from MCP responses when available to get `objectNameSingular`, `recordId`, and `displayName`.
- If `recordReferences` is missing, use the record's `id` and the object name from the tool that returned it.
- If `recordReferences` and workspace origin are both available, the first record-name column or record heading MUST link the display name. Do not output unlinked record names in that case.
- Prefer linking the record display name in tables and summaries instead of adding a raw ID column.
- When showing records from multiple workspaces, generate links with each record's own workspace origin.
- If the workspace origin is unknown, do not invent a hostname. Add a compact `Record` column with the object name and record ID, or say that direct links need the workspace URL.
## Visual Identifiers
Add a small visual identifier next to records when it improves scanning and the source data provides one.
- Twenty AI chat currently renders Markdown with `react-markdown` and `remark-gfm`, without raw HTML rendering. Do not rely on HTML such as `<img width="16" height="16">`.
- Use standard Markdown image syntax only: `![alt](image-url)`.
- The current Twenty AI chat image CSS preserves intrinsic image size with `height: auto` and only caps `max-width`. Do not place full-size photos or large avatars in tables unless the image URL is already a small thumbnail.
- Prefer the record's own `avatarUrl`, logo, or image field when present and non-empty.
- For People, show the person's avatar or profile image only when the URL is known to be a small thumbnail. If there is no suitable image, keep the linked name and do not generate fake headshots.
- For Companies, Workspaces, domains, or records mainly identified by an email/domain, prefer a small favicon or logo when the record provides a safe public website, domain, or avatar URL.
- Derive a domain from `emails.primaryEmail`, `website`, `domainName`, or equivalent fields only for display or favicon lookup; never expose private internal domains as external image requests.
- If image sizing is not reliable, use a text `Domain`, `Company`, or `Source` column instead of an image.
- Always include readable text next to the image. Never make an image the only record label.
- If no trustworthy image or favicon source is available, omit the image instead of showing a broken placeholder.
- Avoid adding images to very large result sets unless the user asks for a visual scan.
- Avoid stacking multiple visual tokens before a record name, such as favicon plus emoji plus linked text. Redundant icons make table alignment harder to scan.
## Dates And Times
Never expose ISO/RFC3339 timestamps as the main date display.
- Parse common technical formats such as `2026-05-05T09:43:18.123Z`, `2026-05-05T09:43:18+02:00`, Unix seconds, and Unix milliseconds.
- Convert instants with `Z` or an explicit offset to the user's timezone when known. If timezone is unknown, keep the source timezone or ask only when it changes the meaning.
- Preserve date-only values as dates. Do not shift date-only values across timezones.
- Display absolute dates. Use relative words such as "today", "yesterday", or "last week" only as a supplement when helpful.
- Include the year unless it is truly redundant in a small same-year table.
- Show seconds and milliseconds only when they matter for debugging, audit logs, or ordering events with near-identical times.
Examples:
- Timestamp: `2026-05-05T09:43:18.123Z` -> `May 5, 2026, 11:43 AM`
- Date-only value: `2026-05-05` -> `May 5, 2026`
If the exact raw timestamp is relevant, put it after the readable value:
```text
Created: May 5, 2026, 11:43 AM (raw: 2026-05-05T09:43:18.123Z)
```
## Field Labels
Convert raw field names into user-facing labels:
- `createdAt` -> `Created`
- `updatedAt` -> `Last updated`
- `deletedAt` -> `Deleted`
- `createdBy` -> `Created by`
- `workspaceMemberId` -> `Workspace member`
- `opportunityStage` -> `Opportunity stage`
Prefer the label users see in Twenty when it is available from metadata. Otherwise, split camelCase, snake_case, and kebab-case into normal words.
## Value Formatting
Format values by meaning:
- Empty or null: `Not set`, or omit if the field is irrelevant.
- Booleans: `Yes` / `No`.
- Money: include currency and grouping, for example `EUR 12,450` or `USD 12,450` based on the record currency.
- Percentages: use `%`, round only enough to stay meaningful.
- URLs and emails: make them clickable Markdown links when useful.
- IDs and UUIDs: hide by default unless the user asks for identifiers, deduplication, debugging, or exact references.
- Arrays: show the count and the most important names, not the full serialized array.
## Record Ordering
When the user asks for "latest", "recent", or "last records":
- State which date field was used when it is not obvious, for example `sorted by Last updated`.
- Prefer `updatedAt` for "recent activity" and `createdAt` for "newest records" unless the user's wording or object semantics points to another date.
- Display the chosen date column in readable form.
- If multiple records share the same date, keep a deterministic secondary order such as name or ID.
## Table Alignment
Make tables easy to scan before making them visually decorative.
- Use Markdown alignment markers intentionally: text columns left-aligned (`:---`), numeric money/count columns right-aligned (`---:`), and short status columns centered only when that actually improves scanning (`:---:`).
- Keep record names on a stable left edge. If rows have favicons, avatars, or logos, prefer a dedicated narrow `Icon`, `Logo`, or `Avatar` column followed by a linked record-name column.
- If the table is compact and the image is known to be consistently small, it is acceptable to put `![alt](url) [Name](record-url)` in one cell. Do not also add emoji or extra symbols before the name.
- Keep fixed-format fields such as `Created`, `Updated`, `Amount`, and `Source` to the right of variable-width fields such as `Name`, `Company`, `Person`, and `Domain`.
- Use a consistent date format within a table so rows line up visually, for example `May 5, 2026, 11:43 AM` or `May 5, 11:43`.
- Prefer natural links over extra link columns: link the record name to Twenty, and link the domain or email only when that external destination is useful.
- Avoid raw ID columns in normal user-facing tables. IDs are long, visually dominant, and destroy alignment unless the user asks for them.
## Markdown Patterns
Use a compact table for comparable records:
```markdown
I found 5 recent opportunities, sorted by last updated date.
| Logo | Name | Stage | Amount | Last updated |
| :---: | :--- | :--- | ---: | :--- |
| ![Acme icon](https://example.com/favicon.ico) | [Acme renewal](https://example.twenty.com/object/opportunity/record-id-1) | Negotiation | EUR 12,450 | May 5, 2026, 11:43 AM |
| ![Globex icon](https://globex.example/favicon.ico) | [Globex expansion](https://example.twenty.com/object/opportunity/record-id-2) | Discovery | EUR 8,000 | May 4, 2026, 4:10 PM |
```
For recent companies with `recordReferences`, link the company name:
```markdown
I found 5 recent companies, sorted by Created.
| Company | Domain | Created |
| :--- | :--- | :--- |
| [Acme](https://workspace.example/object/company/00000000-0000-0000-0000-000000000001) | [acme.example](https://acme.example) | May 5, 2026, 11:43 AM |
```
Use a labelled block for one important record:
```markdown
**[Acme renewal](https://example.twenty.com/object/opportunity/record-id-1)**
- Stage: Negotiation
- Amount: EUR 12,450
- Next action: Not set
- Last updated: May 5, 2026, 11:43 AM
```
## Raw Data Exceptions
Show raw JSON, raw timestamps, internal IDs, or full nested objects only when the user asks for debugging, export, exact API payloads, schema inspection, or reproducible commands. Even then, put a readable summary before the raw block.

View File

@@ -0,0 +1,158 @@
# Set Up Twenty MCP
## Overview
Use this reference to configure a workspace-specific Twenty MCP endpoint. The skill must not assume a fixed workspace domain; collect the user's workspace URL first, normalize it to an MCP URL, then configure and authenticate the MCP client of the agent in use.
Self-hosted instances are first-class targets. Any Twenty workspace works: managed cloud, self-hosted on a custom domain, or a local instance over HTTP. Never require a `twenty.com` workspace.
## Required User Input
Ask for the workspace URL or host if it is missing. Do not invent or reuse a private default domain.
Accept any of these forms:
```text
myworkspace.twenty.com
myworkspace.customdomain.com
myworkspace.localhost:3001
https://myworkspace.twenty.com
https://myworkspace.customdomain.com/mcp
http://myworkspace.localhost:3001/mcp
```
Optional inputs:
- MCP server name. Default to a workspace-derived name in the form `twenty-<host-without-tld>`, for example `acme.example.com` becomes `twenty-acme-example`.
- Whether to force CLI OAuth login immediately. Default to no when the agent opens OAuth on its own after the MCP server is added. Only force login if the automatic OAuth window does not appear.
## URL Normalization
Normalize workspace input as follows:
- If the input already starts with `http://` or `https://`, preserve that scheme.
- If the input has no scheme and is `localhost`, `127.x.x.x`, `[::1]`, `*.localhost`, or `*.localhost:<port>`, use `http://`.
- If the input has no scheme and is any other host, use `https://`.
- Strip any trailing `/` before looking at the path, so `https://acme.example.com/` and `https://acme.example.com/mcp/` normalize the same as their unslashed forms.
- If the final URL does not end with `/mcp`, append `/mcp`.
Examples:
```text
myworkspace.twenty.com -> https://myworkspace.twenty.com/mcp name: twenty-myworkspace
acme.example.com -> https://acme.example.com/mcp name: twenty-acme-example
myworkspace.customdomain.com -> https://myworkspace.customdomain.com/mcp name: twenty-myworkspace-customdomain
myworkspace.localhost:3001 -> http://myworkspace.localhost:3001/mcp name: twenty-myworkspace-localhost-3001
https://acme.example.com/ -> https://acme.example.com/mcp name: twenty-acme-example
https://acme.example.com/mcp/ -> https://acme.example.com/mcp name: twenty-acme-example
```
A trailing slash that survives normalization produces `//mcp` or `/mcp//mcp`, neither of which is the registered endpoint.
## Setup Per Agent
The Twenty MCP server uses streamable HTTP transport with OAuth authentication. Configure it with the agent the user is actually running.
### Codex
```bash
codex mcp add twenty-myworkspace --url https://myworkspace.twenty.com/mcp
codex mcp login twenty-myworkspace
```
For a local instance:
```bash
codex mcp add twenty-local --url http://myworkspace.localhost:3001/mcp
codex mcp login twenty-local
```
OAuth guards specific to this client:
- Do not pass `--login` to `codex mcp add`. It is deprecated.
- Codex can open OAuth on its own when it sees the new MCP server. Run `codex mcp login <server-name>` only if that window does not appear.
- Do not manually run `open <authorization-url>` for an OAuth URL printed by `codex mcp add` unless the user confirms no browser tab opened. Opening the same authorization URL twice creates two callback tabs with the same `state` and different one-time `code` values.
The Twenty marketplace plugin for this client bundles a `setup-mcp.sh` helper that automates URL normalization, server naming, and this add-then-login sequence.
### Claude Code
```bash
claude mcp add --transport http twenty-myworkspace https://myworkspace.twenty.com/mcp
```
For a local instance:
```bash
claude mcp add --transport http twenty-local http://myworkspace.localhost:3001/mcp
```
Claude Code prompts for OAuth when the server is first used. `/mcp` inside a session shows connection and authentication status, and re-runs the OAuth flow on demand.
### Cursor
Add the server to `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project:
```json
{
"mcpServers": {
"twenty-myworkspace": {
"url": "https://myworkspace.twenty.com/mcp"
}
}
}
```
Cursor opens the OAuth flow when the server is enabled in its MCP settings.
### Other MCP Clients
Any client that supports streamable HTTP MCP servers with OAuth works. Configure a server entry pointing at the normalized MCP URL:
```json
{
"mcpServers": {
"twenty-myworkspace": {
"url": "https://myworkspace.twenty.com/mcp"
}
}
}
```
If the client cannot do OAuth, configure bearer headers in the user's private MCP client config only, never in shared or committed files.
## Validation
After setup, verify the server is available with the client's own inspection command (`codex mcp get <server-name>`, `claude mcp list`, or the equivalent), or by listing available tools in a fresh session.
When connected, use the Twenty MCP discovery flow:
```text
learn_tools -> execute_tool
```
## Troubleshooting
### Debugging Workflow
Use this workflow when the user reports missing tools, unexpected workspace data, authentication failures, or suspects the agent queried the wrong Twenty workspace.
1. Identify the intended workspace URL or host from the user's request, using Required User Input when it is missing.
2. Normalize the intended workspace to its MCP URL with the URL Normalization rules above.
3. Inspect the configured Twenty MCP servers and compare their URLs to the intended normalized URL (`codex mcp list`, `claude mcp list`, or the client's MCP config file).
4. If no configured server points at the intended MCP URL, configure one with a workspace-specific name using the per-agent setup above.
5. If the server exists but authentication fails, or MCP startup reports `Auth required`, run the client's OAuth login for that exact server.
6. If the configured server is correct and authenticated but its tools are not visible in the current session, explain that the session may have loaded tools before the server was added or authenticated. Ask the user to reload the agent or start a new session so it picks up the updated MCP config.
7. Before querying workspace data, confirm the callable Twenty MCP namespace or tool name corresponds to the intended server. If only a different Twenty server is callable, do not use it as a fallback unless the user explicitly asks.
8. When reporting results, state which workspace URL or MCP server was used if there was any ambiguity.
### Common Failure Modes
- If OAuth fails for a self-hosted workspace, check that `SERVER_URL` matches the public workspace origin. OAuth metadata and MCP URLs derive from that value.
- If a local workspace fails over HTTPS, use `http://` explicitly per the localhost normalization rule.
- If the server name already exists, replace that entry with the new URL, or pick a different name to keep several workspaces side by side.
### Safety Rules
- Do not put API keys in skill files. Prefer OAuth login. If an MCP client does not support OAuth, configure bearer headers only in the user's private MCP client config.
- Do not inspect or manually extract OAuth tokens as a workaround.

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).`);

View File

@@ -0,0 +1,98 @@
---
name: create-app
description: Use when the user wants to create or scaffold a new Twenty app
---
# When To Use
Pick this skill when the user wants to start a brand-new Twenty app from scratch. Representative triggers:
- "I want to build a Twenty app"
- "scaffold a new Twenty app"
- "start a new Twenty plugin / extension / integration"
- "create a CRM extension for Twenty"
- "set up a Twenty app project"
- "bootstrap a Twenty app called X"
Do not use this skill when the app already exists — use `develop-app` to add features, `manage-app` for sync/deploy/troubleshooting, `publish-app` for marketplace prep, or `use-twenty-mcp` to query workspace data.
# Quickstart an App
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`. Cross-skill operating rules that apply to every Twenty app task are in `../../references/concepts/operating-rules.md`; they are authoritative and not restated per skill.
Use this as the default way to start an app unless the user gives different instructions.
## Why A Twenty Instance Is Needed
Before scaffolding, explain to the user that a Twenty app is not a standalone application — it is a package that extends a running Twenty instance. During development, the app's entities (objects, views, front components, logic functions) are synced to a Twenty instance where they are registered, rendered, and executed. Without a connected instance, there is nothing to sync to, no workspace to test in, and no way to verify the app works.
## Choose A Twenty Instance
By default, ask the user for the URL of their existing Twenty instance (e.g. `https://app.twenty.com` or a self-hosted URL). Mention that if they don't have one yet, you can spin up a local instance with Docker instead.
The two options are:
1. **Existing Twenty instance (default)** — the user provides the URL of a running Twenty server (self-hosted or cloud, e.g. `https://app.twenty.com`). The scaffolder authenticates via OAuth on that instance. Best when the user already has a workspace with data they want to develop against.
2. **Local instance with Docker (fallback)** — only when the user has no Twenty instance available. The scaffolder starts a disposable local Twenty server on `http://localhost:2020` through Docker. Requires Docker Desktop to be installed and running.
If the user does not provide a URL, ask first whether they have a Twenty instance URL to use; only fall back to Docker if they explicitly say they don't have one.
Before scaffolding, repeat back the app's purpose in one sentence and its expected shape: standard objects extended, any custom objects, whether it needs UI, whether it needs workflows or post-install seeding. Scaffolding is one-way — confirming here avoids re-scaffolds later.
## Scaffolding
First, ask the user for the app name if they did not provide one.
The directory name must contain only lowercase letters, numbers, and hyphens. Transform the entered name to lowercase and replace spaces with hyphens when needed.
For an existing Twenty instance (default):
```bash
npx create-twenty-app@latest <app-name> --url <twenty-instance-url>
```
The `--url` flag authenticates via OAuth on the provided instance. The scaffolder opens a browser for the OAuth flow, then stores the credentials as a remote in `~/.twenty/config.json`.
Only if the user has no Twenty instance and wants to try locally with Docker:
```bash
npx create-twenty-app@latest <app-name>
```
This omits `--url` and starts a disposable local Twenty server through Docker.
The scaffolder handles everything: it creates the project, enables corepack, installs dependencies, initializes Git, authenticates with the target instance, runs an initial sync, and opens the generated app page when possible.
If the user provides a package name, display name, or description, pass them through:
```bash
npx create-twenty-app@latest <app-directory> --name "<package-name>" --display-name "<display-name>" --description "<description>"
```
Supported create-time options are `--name`, `--display-name`, `--description`, `--url`, and `--authentication-method`.
## After Scaffolding
When the scaffolder completes, the app is fully created, synced, and installed. The job is done.
Do not run any follow-up validation commands after scaffolding unless the user asks for them. Do not run `yarn twenty apply`, `yarn test`, `yarn lint`, or other validation just to prove the scaffold worked; the scaffolder already performed the initial sync. If the user asks to run tests later, switch to `develop-app` or `manage-app` guidance and run the full suite against the isolated test instance with `TWENTY_API_URL=http://localhost:2021`.
Report to the user that the app was created successfully and is ready for development. Then stop. Wait for the user to ask for the next action.
The scaffolder generates a placeholder page at `src/front-components/main-page.tsx` plus its page layout and navigation menu item. In `develop-app`, delete all three before the first deploy unless the app actually needs UI. Do not stack additional pages on top of the placeholder.
## Docker Fallback Troubleshooting
Use this only when the user opted into the Docker fallback and it fails because Docker is missing or not running.
The preferred recovery is to ask the user for an existing Twenty instance URL and rerun the scaffolder with `--url <twenty-instance-url>` — this skips Docker entirely.
If the user still wants the local Docker path and Docker is missing, share this download link: `https://www.docker.com/products/docker-desktop/` and ask them to install Docker Desktop.
# Next Steps
Only proceed to these when the user explicitly asks:
- Use `develop-app` when the user wants to add objects, fields, logic functions, roles, views, navigation, page layouts, skills, agents, or front component registrations.
- Use `../../references/design/front-component-ui.md` when the user wants to design or improve the UI of a Twenty front component.
- When the user later makes changes to app entities, use `yarn twenty apply` to sync those changes. See the `manage-app` skill for sync workflow.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Create App"
short_description: "Scaffold and develop Twenty apps."
default_prompt: "Use $create-app to scaffold and run a new Twenty app."

View File

@@ -0,0 +1,116 @@
---
name: develop-app
description: Use when the user wants to add or modify Twenty app entities, including objects, layouts, logic functions, and front components inside an existing Twenty app.
---
# When To Use
Pick this skill when the user wants to change what an existing Twenty app *does* — its data model, UI, logic, or workflows. Representative triggers:
- "add a new object to my Twenty app"
- "create a logic function that runs on record create"
- "add a custom field to the company object"
- "build a front component for the deal page"
- "modify an existing entity / object / field"
- "add a workflow with a manual trigger"
- "add roles and permissions to my app"
- "create a standalone page in my app"
Do not use this skill to scaffold a brand-new app (use `create-app`), to sync/deploy/troubleshoot (use `manage-app`), to prepare marketplace assets (use `publish-app`), or to query workspace records (use `use-twenty-mcp`).
# Boundaries
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`. Cross-skill operating rules that apply to every Twenty app task are in `../../references/concepts/operating-rules.md`; they are authoritative and not restated per skill.
Do not scaffold a new app here. Use `create-app` first when the app does not exist.
# Workflow
First, confirm the setup is sane before changing entities. The current directory should be the root of an existing Twenty app:
```bash
test -f package.json
test -f src/application-config.ts
```
If either file is missing, do not edit blindly. Inspect nearby folders to find the app root, or use `create-app` when the app does not exist.
If setup, dependencies, remotes, authentication, sync, build, deploy, logs, or CI/CD are failing, switch to `manage-app` before continuing with entity work.
For app shape and entity file structure, read `../../references/develop-app/app-structure.md`.
## Plan Before Editing
For any change involving more than one entity, state the plan back in 3–6 lines before editing: objects extended, fields added, logic functions and post-install hooks declared, whether the app needs UI. Confirm with the user when there is a real choice.
Skip for single-entity edits, renames, and copy fixes.
## Code Organization
Keep logic functions, post-install hooks, and front components narrow. Extract everything that is not the trigger, inputs, writes, rendering shell, or external call:
- `src/utils/<name>.util.ts` — pure helpers (parsers, mappers, formatters).
- `src/front-components/utils/<name>.util.ts` — front-component runtime helpers that are reusable and testable.
- `src/types/<name>.ts` — external API and internal DTO types (one PascalCase type per file).
- `src/<service>-client/<name>.ts` — wrappers around external SDKs or shared HTTP clients. One folder per service, matching Twenty's `*-client` convention.
Kebab-case filenames. One export per file is mandatory for every helper, type, and client file in `src/` — never multiple function exports in one file. The rule counts exports: a local (non-exported) type may stay alongside the util, but once a type is exported or reused it splits into a `src/types/<name>.ts` file separate from the `src/utils/<name>.util.ts` file. See `app-structure.md` for the canonical rule and the full suffix convention.
Refactor when:
- A `*.logic-function.ts` or `*.post-install.ts` file exceeds the soft cap (see `../../references/develop-app/logic.md`).
- A front component contains duplicated command execution, record loading, logic-function lookup, payload building, result parsing, or snackbar formatting.
- The same parsing or mapping logic appears across object types.
- Multiple field files differ only by name and identifier — use a factory under `src/fields/`.
Always create a `*.spec.ts` test file in a sibling `__tests__/` folder for every util/function you add or change, one spec file per source file. See `../../references/develop-app/tests.md`.
When a front component triggers a logic function for selected records, always prefer a bulk-capable logic function unless the user explicitly states that the function is only for one record. The default payload shape is `records: Array<{ id: string; ...fields }>`: inside `records`, use `id` for the Twenty record ID because the array name already establishes the record context. Do not add flat single-record compatibility payloads unless the user explicitly asks to preserve an existing single-record API.
## Adding New Entities
Use the app CLI to add new entities. It generates the correct file structure, UUIDs, SDK imports, and boilerplate automatically:
```bash
yarn twenty dev:add
```
This is the default and preferred way to create objects, fields, views, logic functions, front components, and other entities. Do not manually create entity files, explore SDK typings in `node_modules`, or generate UUIDs by hand when the CLI can do it.
Only create entity files manually when modifying existing entities or when the CLI does not support the specific entity type.
## After Entity Changes
Once all edits for the change are complete, run lint and typecheck once at the end (not after each individual edit), then sync the app to the active remote:
```bash
yarn twenty dev:typecheck
yarn lint
yarn twenty apply
```
`yarn twenty dev:typecheck` checks generated app types, `yarn lint` checks local lint rules, and `yarn twenty apply` syncs entity definitions to the active remote. Run all three a single time once every edit is done, not repeatedly after each step. When the user explicitly asks to run tests, follow `../../references/develop-app/tests.md`.
Use the official Twenty docs or local SDK source when exact entity fields, imports, or configuration shapes matter.
# Develop References
Read the smallest reference that matches the requested entity work:
- Objects, fields, relations, roles, and permissions: `../../references/develop-app/data-model.md`
- Views, navigation, page layouts, page layout tabs, and front component registration: `../../references/develop-app/layout.md`
- Full-page custom UI and standalone page patterns: `../../references/develop-app/standalone-pages.md`
- Front component source, Twenty UI imports, data hooks, runtime imports, and browser verification: `../../references/develop-app/front-components.md`
- Logic functions, skills, agents, post-install hooks, and connection providers: `../../references/develop-app/logic.md`
- Workflows, manual triggers, draft/activate lifecycle, and seeder pitfalls: `../../references/develop-app/workflows.md`
- Tests — what to cover and where to put the files: `../../references/develop-app/tests.md`
- App file structure and entity validation checklist: `../../references/develop-app/app-structure.md`
- Detailed front component UI design: `../../references/design/front-component-ui.md`
For front components, read `front-components.md` before implementation. 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.
# Handoffs
Use `../../references/design/front-component-ui.md` when the entity work turns into front component UI design.
Use `publish-app` when the task turns into README, marketplace copy, screenshots, logos, or listing assets.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Develop App"
short_description: "Add entities and front components to a Twenty app."
default_prompt: "Use $develop-app to add objects, fields, front components, logic functions, views, roles, or app entities to a Twenty app."

Some files were not shown because too many files have changed in this diff Show More