From 175df59c21bc8de0cb103fe58094ded4c1992315 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:45:42 +0100 Subject: [PATCH] Support `Skill` in manifest (#18092) # Introduction Support skill in manifest, pre-requisite for the twenty standard app migration --- packages/create-twenty-app/README.md | 4 +- .../src/create-app.command.ts | 8 ++ .../src/types/scaffolding-options.ts | 1 + .../src/utils/__tests__/app-template.spec.ts | 12 ++- .../src/utils/app-template.ts | 38 +++++++++ .../developers/extend/capabilities/apps.mdx | 47 ++++++++++- packages/twenty-sdk/README.md | 6 +- .../app-dev/expected-manifest.ts | 1 + .../app-dev/expected-manifest.ts | 1 + .../src/cli/commands/entity/entity-add.ts | 11 +++ .../__tests__/manifest-validate.spec.ts | 1 + .../build/manifest/manifest-build.ts | 15 ++++ .../build/manifest/manifest-extract-config.ts | 3 + .../dev-mode-orchestrator-state.ts | 1 + .../cli/utilities/dev/ui/dev-ui-constants.ts | 1 + .../utilities/entity/entity-skill-template.ts | 26 +++++++ .../sdk/common/types/define-entity.type.ts | 2 + packages/twenty-sdk/src/sdk/index.ts | 1 + .../twenty-sdk/src/sdk/skills/define-skill.ts | 25 ++++++ ...cation-manifest-metadata-names.constant.ts | 1 + ...est-all-universal-flat-entity-maps.util.ts | 15 ++++ ...l-manifest-to-universal-flat-skill.util.ts | 27 +++++++ ...rkspace-migration.integration-spec.ts.snap | 74 ++++++++++++++++++ ...n-object-system-fields.integration-spec.ts | 1 + ...on-workspace-migration.integration-spec.ts | 78 ++++++++++++++++++- .../enums/syncable-entities.enum.ts | 1 + .../twenty-shared/src/application/index.ts | 1 + .../src/application/manifestType.ts | 2 + .../src/application/skillManifestType.ts | 9 +++ 29 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts create mode 100644 packages/twenty-sdk/src/sdk/skills/define-skill.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util.ts create mode 100644 packages/twenty-shared/src/application/skillManifestType.ts diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index e733e83da1..d2b9876064 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -89,6 +89,7 @@ In interactive mode, you can pick from: - **Example front component** — a React UI component (`front-components/hello-world.tsx`) - **Example view** — a saved view for the example object (`views/example-view.ts`) - **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`) +- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`) ## What gets scaffolded @@ -106,11 +107,12 @@ In interactive mode, you can pick from: - `front-components/hello-world.tsx` — Example front component - `views/example-view.ts` — Example saved view for the example object - `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link +- `skills/example-skill.ts` — Example AI agent skill definition ## Next steps - Run `yarn twenty help` to see all available commands. - Use `yarn twenty auth:login` to authenticate with your Twenty workspace. -- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items). +- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills). - Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. - Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`. diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 9f2d77e463..07f2277f15 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -114,6 +114,7 @@ export class CreateAppCommand { includeExampleFrontComponent: false, includeExampleView: false, includeExampleNavigationMenuItem: false, + includeExampleSkill: false, }; } @@ -125,6 +126,7 @@ export class CreateAppCommand { includeExampleFrontComponent: true, includeExampleView: true, includeExampleNavigationMenuItem: true, + includeExampleSkill: true, }; } @@ -164,6 +166,11 @@ export class CreateAppCommand { value: 'navigationMenuItem', checked: true, }, + { + name: 'Example skill (AI agent skill definition)', + value: 'skill', + checked: true, + }, ], }, ]); @@ -189,6 +196,7 @@ export class CreateAppCommand { includeExampleView: includeView, includeExampleNavigationMenuItem: selectedExamples.includes('navigationMenuItem'), + includeExampleSkill: selectedExamples.includes('skill'), }; } diff --git a/packages/create-twenty-app/src/types/scaffolding-options.ts b/packages/create-twenty-app/src/types/scaffolding-options.ts index c4e2d2baac..4ffd815ca3 100644 --- a/packages/create-twenty-app/src/types/scaffolding-options.ts +++ b/packages/create-twenty-app/src/types/scaffolding-options.ts @@ -7,4 +7,5 @@ export type ExampleOptions = { includeExampleFrontComponent: boolean; includeExampleView: boolean; includeExampleNavigationMenuItem: boolean; + includeExampleSkill: boolean; }; diff --git a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts index 377586d07e..a614f5776c 100644 --- a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts +++ b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts @@ -1,8 +1,8 @@ -import * as fs from 'fs-extra'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { copyBaseApplicationProject } from '@/utils/app-template'; import { type ExampleOptions } from '@/types/scaffolding-options'; +import { copyBaseApplicationProject } from '@/utils/app-template'; +import * as fs from 'fs-extra'; +import { tmpdir } from 'os'; +import { join } from 'path'; // Mock fs-extra's copy function to skip copying base template (not available during tests) jest.mock('fs-extra', () => { @@ -23,11 +23,13 @@ const ALL_EXAMPLES: ExampleOptions = { includeExampleFrontComponent: true, includeExampleView: true, includeExampleNavigationMenuItem: true, + includeExampleSkill: true, }; const NO_EXAMPLES: ExampleOptions = { includeExampleObject: false, includeExampleField: false, + includeExampleSkill: false, includeExampleLogicFunction: false, includeExampleFrontComponent: false, includeExampleView: false, @@ -437,6 +439,7 @@ describe('copyBaseApplicationProject', () => { exampleOptions: { includeExampleObject: false, includeExampleField: false, + includeExampleSkill: false, includeExampleLogicFunction: false, includeExampleFrontComponent: true, includeExampleView: false, @@ -472,6 +475,7 @@ describe('copyBaseApplicationProject', () => { appDirectory: testAppDirectory, exampleOptions: { includeExampleObject: false, + includeExampleSkill: false, includeExampleField: false, includeExampleLogicFunction: true, includeExampleFrontComponent: false, diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index 3cab26f1aa..a1afca9bfb 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -89,6 +89,14 @@ export const copyBaseApplicationProject = async ({ }); } + if (exampleOptions.includeExampleSkill) { + await createExampleSkill({ + appDirectory: sourceFolderPath, + fileFolder: 'skills', + fileName: 'example-skill.ts', + }); + } + await createDefaultPostInstallFunction({ appDirectory: sourceFolderPath, fileFolder: 'logic-functions', @@ -424,6 +432,36 @@ export default defineNavigationMenuItem({ await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); }; +const createExampleSkill = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineSkill } from 'twenty-sdk'; + +export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineSkill({ + universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER, + name: 'example-skill', + label: 'Example Skill', + description: 'A sample skill for your application', + icon: 'IconBrain', + content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.', +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + const createApplicationConfig = async ({ displayName, description, diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index b6a9e883d8..faad8edc8d 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -14,6 +14,7 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf **What you can do today:** - Define custom objects and fields as code (managed data model) - Build logic functions with custom triggers +- Define skills for AI agents - Deploy the same app across multiple workspaces ## Prerequisites @@ -44,7 +45,7 @@ yarn twenty app:dev The scaffolder supports three modes for controlling which example files are included: ```bash filename="Terminal" -# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item) +# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill) npx create-twenty-app@latest my-app # Minimal: only core files (application-config.ts and default-role.ts) @@ -117,8 +118,10 @@ my-twenty-app/ │ └── hello-world.tsx # Example front component ├── views/ │ └── example-view.ts # Example saved view definition - └── navigation-menu-items/ - └── example-navigation-menu-item.ts # Example sidebar navigation link + ├── navigation-menu-items/ + │ └── example-navigation-menu-item.ts # Example sidebar navigation link + └── skills/ + └── example-skill.ts # Example AI agent skill definition ``` With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include. @@ -147,6 +150,7 @@ The SDK detects entities by parsing your TypeScript files for **`export default | `defineField()` | Field extensions for existing objects | | `defineView()` | Saved view definitions | | `defineNavigationMenuItem()` | Navigation menu item definitions | +| `defineSkill()` | AI agent skill definitions | **File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement. @@ -167,7 +171,7 @@ export default defineObject({ Later commands will add more files and folders: - `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types). -- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, or roles. +- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more. ## Authentication @@ -220,6 +224,7 @@ The SDK provides helper functions for defining your app entities. As described i | `defineField()` | Extend existing objects with additional fields | | `defineView()` | Define saved views for objects | | `defineNavigationMenuItem()` | Define sidebar navigation links | +| `defineSkill()` | Define AI agent skills | These functions validate your configuration at build time and provide IDE autocompletion and type safety. @@ -729,6 +734,40 @@ You can create new front components in two ways: - **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component. - **Manual**: Create a new `*.front-component.tsx` file and use `defineFrontComponent()`. +### Skills + +Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation: + +```typescript +// src/skills/example-skill.ts +import { defineSkill } from 'twenty-sdk'; + +export default defineSkill({ + universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'sales-outreach', + label: 'Sales Outreach', + description: 'Guides the AI agent through a structured sales outreach process', + icon: 'IconBrain', + content: `You are a sales outreach assistant. When reaching out to a prospect: +1. Research the company and recent news +2. Identify the prospect's role and likely pain points +3. Draft a personalized message referencing specific details +4. Keep the tone professional but conversational`, +}); +``` + +Key points: +- `name` is a unique identifier string for the skill (kebab-case recommended). +- `label` is the human-readable display name shown in the UI. +- `content` contains the skill instructions — this is the text the AI agent uses. +- `icon` (optional) sets the icon displayed in the UI. +- `description` (optional) provides additional context about the skill's purpose. + +You can create new skills in two ways: + +- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill. +- **Manual**: Create a new file and use `defineSkill()`, following the same pattern. + ### Generated typed client The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions: diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 9068a1e6d9..33f0a7dbbb 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -130,7 +130,7 @@ Application development commands. - `twenty entity:add [entityType]` — Add a new entity to your application. - Arguments: - - `entityType`: one of `object`, `field`, `function`, `front-component`, `role`, `view`, or `navigation-menu-item`. If omitted, an interactive prompt is shown. + - `entityType`: one of `object`, `field`, `function`, `front-component`, `role`, `view`, `navigation-menu-item`, or `skill`. If omitted, an interactive prompt is shown. - Options: - `--path `: The path where the entity file should be created (relative to the current directory). - Behavior: @@ -141,6 +141,7 @@ Application development commands. - `role`: prompts for a name and scaffolds a `*.role.ts` role definition file. - `view`: prompts for a name and target object, then creates a `*.view.ts` definition file. - `navigation-menu-item`: prompts for a name and scaffolds a `*.navigation-menu-item.ts` file. + - `skill`: prompts for a name and scaffolds a `*.skill.ts` skill definition file. ### Function @@ -180,6 +181,9 @@ twenty entity:add view # Add a new navigation menu item twenty entity:add navigation-menu-item +# Add a new skill +twenty entity:add skill + # Uninstall the app from the workspace twenty app:uninstall diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index 1a910bd8f7..9973bf2ae3 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -18,6 +18,7 @@ export const EXPECTED_MANIFEST: Manifest = { fileType: 'png', }, ], + skills: [], application: { applicationVariables: { DEFAULT_RECIPIENT_NAME: { diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts index 2ee45c6d94..dab295ebfa 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts @@ -13,6 +13,7 @@ export const EXPECTED_MANIFEST: Manifest = { yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e', apiClientChecksum: null, }, + skills: [], publicAssets: [], fields: [], objects: [ diff --git a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts index 86d59380d6..2b9bc8aab5 100644 --- a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts +++ b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts @@ -6,6 +6,7 @@ import { convertToLabel } from '@/cli/utilities/entity/entity-label'; import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template'; import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template'; import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template'; +import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template'; import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template'; import chalk from 'chalk'; import * as fs from 'fs-extra'; @@ -116,6 +117,16 @@ export class EntityAddCommand { return { name, file }; } + case SyncableEntity.Skill: { + const name = await this.getEntityName(entity); + + const file = getSkillBaseFile({ + name, + }); + + return { name, file }; + } + case SyncableEntity.View: { const entityData = await this.getViewData(); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 09ac3a63d3..d4aa3e99ad 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -32,6 +32,7 @@ const validManifest: Manifest = { fields: [], logicFunctions: [], roles: [], + skills: [], publicAssets: [], views: [], navigationMenuItems: [], diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index c9d5e36431..452f5791af 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -29,6 +29,7 @@ import { type ObjectManifest, type PageLayoutManifest, type RoleManifest, + type SkillManifest, type ViewManifest, } from 'twenty-shared/application'; import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function'; @@ -64,6 +65,7 @@ export const buildManifest = async ( const objects: ObjectManifest[] = []; const fields: FieldManifest[] = []; const roles: RoleManifest[] = []; + const skills: SkillManifest[] = []; const logicFunctions: LogicFunctionManifest[] = []; const frontComponents: FrontComponentManifest[] = []; const publicAssets: AssetManifest[] = []; @@ -75,6 +77,7 @@ export const buildManifest = async ( const objectsFilePaths: string[] = []; const fieldsFilePaths: string[] = []; const rolesFilePaths: string[] = []; + const skillsFilePaths: string[] = []; const logicFunctionsFilePaths: string[] = []; const frontComponentsFilePaths: string[] = []; const publicAssetsFilePaths: string[] = []; @@ -165,6 +168,16 @@ export const buildManifest = async ( rolesFilePaths.push(relativePath); break; } + case ManifestEntityKey.Skills: { + const extract = await extractManifestFromFile({ + appPath, + filePath, + }); + skills.push(extract.config); + errors.push(...extract.errors); + skillsFilePaths.push(relativePath); + break; + } case ManifestEntityKey.LogicFunctions: { const extract = await extractManifestFromFile({ appPath, @@ -295,6 +308,7 @@ export const buildManifest = async ( objects, fields, roles, + skills, logicFunctions, frontComponents, publicAssets, @@ -308,6 +322,7 @@ export const buildManifest = async ( objects: objectsFilePaths, fields: fieldsFilePaths, roles: rolesFilePaths, + skills: skillsFilePaths, logicFunctions: logicFunctionsFilePaths, frontComponents: frontComponentsFilePaths, publicAssets: publicAssetsFilePaths, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 2f915de829..3ff34fe6d5 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -6,6 +6,7 @@ export enum TargetFunction { DefineLogicFunction = 'defineLogicFunction', DefineObject = 'defineObject', DefineRole = 'defineRole', + DefineSkill = 'defineSkill', DefineFrontComponent = 'defineFrontComponent', DefineView = 'defineView', DefineNavigationMenuItem = 'defineNavigationMenuItem', @@ -18,6 +19,7 @@ export enum ManifestEntityKey { LogicFunctions = 'logicFunctions', Objects = 'objects', Roles = 'roles', + Skills = 'skills', FrontComponents = 'frontComponents', PublicAssets = 'publicAssets', Views = 'views', @@ -36,6 +38,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< [TargetFunction.DefineLogicFunction]: ManifestEntityKey.LogicFunctions, [TargetFunction.DefineObject]: ManifestEntityKey.Objects, [TargetFunction.DefineRole]: ManifestEntityKey.Roles, + [TargetFunction.DefineSkill]: ManifestEntityKey.Skills, [TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents, [TargetFunction.DefineView]: ManifestEntityKey.Views, [TargetFunction.DefineNavigationMenuItem]: diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 99b56acb51..a5fe63648c 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -69,6 +69,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record = { logicFunctions: SyncableEntity.LogicFunction, frontComponents: SyncableEntity.FrontComponent, roles: SyncableEntity.Role, + skills: SyncableEntity.Skill, views: SyncableEntity.View, navigationMenuItems: SyncableEntity.NavigationMenuItem, pageLayouts: SyncableEntity.PageLayout, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index 57aa58c963..9375a54724 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -97,6 +97,7 @@ export const ENTITY_LABELS: Record = { [SyncableEntity.LogicFunction]: 'Logic functions', [SyncableEntity.FrontComponent]: 'Front components', [SyncableEntity.Role]: 'Roles', + [SyncableEntity.Skill]: 'Skills', [SyncableEntity.View]: 'Views', [SyncableEntity.NavigationMenuItem]: 'Navigation menu items', [SyncableEntity.PageLayout]: 'Page layouts', diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts new file mode 100644 index 0000000000..29a36569e0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts @@ -0,0 +1,26 @@ +import kebabCase from 'lodash.kebabcase'; +import { v4 } from 'uuid'; + +export const getSkillBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { + const kebabCaseName = kebabCase(name); + + return `import { defineSkill } from 'twenty-sdk'; + +export const ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_SKILL_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineSkill({ + universalIdentifier: ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_SKILL_UNIVERSAL_IDENTIFIER, + name: '${kebabCaseName}', + label: '${name}', + description: 'Add a description for your skill', + content: 'Add the skill content here', +}); +`; +}; diff --git a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts index ba3d83ef18..03aa94a763 100644 --- a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts @@ -8,6 +8,7 @@ import { type FieldManifest, type NavigationMenuItemManifest, type RoleManifest, + type SkillManifest, } from 'twenty-shared/application'; export type ValidationResult = { @@ -23,6 +24,7 @@ export type DefinableEntity = | FrontComponentConfig | LogicFunctionConfig | RoleManifest + | SkillManifest | ViewConfig | NavigationMenuItemManifest | PageLayoutConfig; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 2c1acadd1b..a6c99f9383 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -59,6 +59,7 @@ export { export type { PageLayoutWidgetUniversalConfiguration } from 'twenty-shared/types'; export { defineRole } from './roles/define-role'; export { PermissionFlag } from './roles/permission-flag-type'; +export { defineSkill } from './skills/define-skill'; export { defineView } from './views/define-view'; export type { ViewConfig } from './views/view-config'; diff --git a/packages/twenty-sdk/src/sdk/skills/define-skill.ts b/packages/twenty-sdk/src/sdk/skills/define-skill.ts new file mode 100644 index 0000000000..e79cc26e0c --- /dev/null +++ b/packages/twenty-sdk/src/sdk/skills/define-skill.ts @@ -0,0 +1,25 @@ +import { type DefineEntity } from '@/sdk/common/types/define-entity.type'; +import { createValidationResult } from '@/sdk/common/utils/create-validation-result'; +import { type SkillManifest } from 'twenty-shared/application'; + +export const defineSkill: DefineEntity = (config) => { + const errors: string[] = []; + + if (!config.universalIdentifier) { + errors.push('Skill must have a universalIdentifier'); + } + + if (!config.name) { + errors.push('Skill must have a name'); + } + + if (!config.label) { + errors.push('Skill must have a label'); + } + + if (!config.content) { + errors.push('Skill must have content'); + } + + return createValidationResult({ config, errors }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts index 859b026f04..0ba668580c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts @@ -6,6 +6,7 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [ 'logicFunction', 'frontComponent', 'role', + 'skill', 'view', 'viewField', 'viewFieldGroup', diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index 4f2fb11ea8..87eb4636c3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -11,6 +11,7 @@ import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util'; import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util'; import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/utils/from-role-manifest-to-universal-flat-role.util'; +import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util'; import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/utils/from-view-field-group-manifest-to-universal-flat-view-field-group.util'; import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util'; import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/utils/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util'; @@ -153,6 +154,20 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ ); } + for (const skillManifest of manifest.skills ?? []) { + addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow( + { + metadataName: 'skill', + universalFlatEntity: fromSkillManifestToUniversalFlatSkill({ + skillManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps, + }, + ); + } + for (const viewManifest of manifest.views ?? []) { addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow( { diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util.ts new file mode 100644 index 0000000000..9c757fc12b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util.ts @@ -0,0 +1,27 @@ +import { type SkillManifest } from 'twenty-shared/application'; + +import { type UniversalFlatSkill } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-skill.type'; + +export const fromSkillManifestToUniversalFlatSkill = ({ + skillManifest, + applicationUniversalIdentifier, + now, +}: { + skillManifest: SkillManifest; + applicationUniversalIdentifier: string; + now: string; +}): UniversalFlatSkill => { + return { + universalIdentifier: skillManifest.universalIdentifier, + applicationUniversalIdentifier, + name: skillManifest.name, + label: skillManifest.label, + icon: skillManifest.icon ?? null, + description: skillManifest.description ?? null, + content: skillManifest.content, + isCustom: false, + isActive: true, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap index b42d4e2918..3a135cbecc 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap @@ -348,3 +348,77 @@ exports[`syncApplication should return workspace migration actions on initial sy }, } `; + +exports[`syncApplication should sync a skill then update it on second sync 1`] = ` +{ + "syncApplication": { + "actions": [ + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "canAccessAllTools": false, + "canBeAssignedToAgents": true, + "canBeAssignedToApiKeys": true, + "canBeAssignedToUsers": true, + "canDestroyAllObjectRecords": false, + "canReadAllObjectRecords": false, + "canSoftDeleteAllObjectRecords": false, + "canUpdateAllObjectRecords": false, + "canUpdateAllSettings": false, + "createdAt": Any, + "description": "A test role", + "icon": null, + "isEditable": true, + "label": "Test Role", + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "role", + "type": "create", + }, + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "content": "# Test Skill + +This is a test skill.", + "createdAt": Any, + "description": "A skill for testing", + "icon": "IconBrain", + "isActive": true, + "isCustom": false, + "label": "Test Skill", + "name": "test-skill", + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "skill", + "type": "create", + }, + ], + "applicationUniversalIdentifier": Any, + }, +} +`; + +exports[`syncApplication should sync a skill then update it on second sync 2`] = ` +{ + "syncApplication": { + "actions": [ + { + "metadataName": "skill", + "type": "update", + "universalIdentifier": Any, + "update": { + "content": "# Test Skill + +This is an updated test skill with more content.", + "description": "An updated skill for testing", + "label": "Test Skill Updated", + }, + }, + ], + "applicationUniversalIdentifier": Any, + }, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts index 094bc96aa1..979a4a95c1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-object-system-fields.integration-spec.ts @@ -48,6 +48,7 @@ const buildBaseManifest = ( views: [], navigationMenuItems: [], pageLayouts: [], + skills: [], ...overrides, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts index ea4a01df36..6e90d88f06 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts @@ -11,6 +11,7 @@ const TEST_APP_ID = uuidv4(); const TEST_ROLE_ID = uuidv4(); const TEST_SECOND_ROLE_ID = uuidv4(); const TEST_FIELD_ID = uuidv4(); +const TEST_SKILL_ID = uuidv4(); const TEST_OBJECT = buildDefaultObjectManifest({ nameSingular: 'ticket', @@ -35,7 +36,7 @@ describe('syncApplication', () => { appCreated = true; }, 60000); - afterAll(async () => { + afterEach(async () => { if (!appCreated) { return; } @@ -66,6 +67,7 @@ describe('syncApplication', () => { description: 'A test role', }, ], + skills: [], objects: [TEST_OBJECT], fields: [ { @@ -131,4 +133,78 @@ describe('syncApplication', () => { extractRecordIdsAndDatesAsExpectAny(secondSyncData), ); }, 60000); + + it('should sync a skill then update it on second sync', async () => { + const initialManifest: Manifest = { + application: { + universalIdentifier: TEST_APP_ID, + defaultRoleUniversalIdentifier: TEST_ROLE_ID, + displayName: 'Test Application', + description: 'A test application for workspace migration', + icon: 'IconTestPipe', + applicationVariables: {}, + packageJsonChecksum: null, + yarnLockChecksum: null, + apiClientChecksum: null, + }, + roles: [ + { + universalIdentifier: TEST_ROLE_ID, + label: 'Test Role', + description: 'A test role', + }, + ], + skills: [ + { + universalIdentifier: TEST_SKILL_ID, + name: 'test-skill', + label: 'Test Skill', + description: 'A skill for testing', + icon: 'IconBrain', + content: '# Test Skill\n\nThis is a test skill.', + }, + ], + objects: [], + fields: [], + logicFunctions: [], + frontComponents: [], + publicAssets: [], + views: [], + navigationMenuItems: [], + pageLayouts: [], + }; + + const { data: firstSyncData } = await syncApplication({ + manifest: initialManifest, + expectToFail: false, + }); + + expect(firstSyncData).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny(firstSyncData), + ); + + const updatedManifest: Manifest = { + ...initialManifest, + skills: [ + { + universalIdentifier: TEST_SKILL_ID, + name: 'test-skill', + label: 'Test Skill Updated', + description: 'An updated skill for testing', + icon: 'IconBrain', + content: + '# Test Skill\n\nThis is an updated test skill with more content.', + }, + ], + }; + + const { data: secondSyncData } = await syncApplication({ + manifest: updatedManifest, + expectToFail: false, + }); + + expect(secondSyncData).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny(secondSyncData), + ); + }, 60000); }); diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts index 6af01e49e5..e581459962 100644 --- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -4,6 +4,7 @@ export enum SyncableEntity { LogicFunction = 'logicFunction', FrontComponent = 'frontComponent', Role = 'role', + Skill = 'skill', View = 'view', NavigationMenuItem = 'navigationMenuItem', PageLayout = 'pageLayout', diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index c9a4cd658d..0346cce14a 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -50,6 +50,7 @@ export type { FieldPermissionManifest, RoleManifest, } from './roleManifestType'; +export type { SkillManifest } from './skillManifestType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; export type { ViewManifestFilterValue, diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index 7cc37a96ca..a86b08defa 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -7,6 +7,7 @@ import { type NavigationMenuItemManifest } from './navigationMenuItemManifestTyp import { type ObjectManifest } from './objectManifestType'; import { type PageLayoutManifest } from './pageLayoutManifestType'; import { type RoleManifest } from './roleManifestType'; +import { type SkillManifest } from './skillManifestType'; import { type ViewManifest } from './viewManifestType'; export type Manifest = { @@ -16,6 +17,7 @@ export type Manifest = { logicFunctions: LogicFunctionManifest[]; frontComponents: FrontComponentManifest[]; roles: RoleManifest[]; + skills: SkillManifest[]; publicAssets: AssetManifest[]; views: ViewManifest[]; navigationMenuItems: NavigationMenuItemManifest[]; diff --git a/packages/twenty-shared/src/application/skillManifestType.ts b/packages/twenty-shared/src/application/skillManifestType.ts new file mode 100644 index 0000000000..e53a03f12f --- /dev/null +++ b/packages/twenty-shared/src/application/skillManifestType.ts @@ -0,0 +1,9 @@ +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type SkillManifest = SyncableEntityOptions & { + name: string; + label: string; + icon?: string; + description?: string; + content: string; +};