Support Skill in manifest (#18092)
# Introduction Support skill in manifest, pre-requisite for the twenty standard app migration
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ export type ExampleOptions = {
|
||||
includeExampleFrontComponent: boolean;
|
||||
includeExampleView: boolean;
|
||||
includeExampleNavigationMenuItem: boolean;
|
||||
includeExampleSkill: boolean;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
<Note>
|
||||
**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define<Entity>({...})` 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:
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
fileType: 'png',
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
application: {
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
|
||||
apiClientChecksum: null,
|
||||
},
|
||||
skills: [],
|
||||
publicAssets: [],
|
||||
fields: [],
|
||||
objects: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ const validManifest: Manifest = {
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
roles: [],
|
||||
skills: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
|
||||
@@ -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<SkillManifest>({
|
||||
appPath,
|
||||
filePath,
|
||||
});
|
||||
skills.push(extract.config);
|
||||
errors.push(...extract.errors);
|
||||
skillsFilePaths.push(relativePath);
|
||||
break;
|
||||
}
|
||||
case ManifestEntityKey.LogicFunctions: {
|
||||
const extract = await extractManifestFromFile<LogicFunctionConfig>({
|
||||
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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -69,6 +69,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
|
||||
logicFunctions: SyncableEntity.LogicFunction,
|
||||
frontComponents: SyncableEntity.FrontComponent,
|
||||
roles: SyncableEntity.Role,
|
||||
skills: SyncableEntity.Skill,
|
||||
views: SyncableEntity.View,
|
||||
navigationMenuItems: SyncableEntity.NavigationMenuItem,
|
||||
pageLayouts: SyncableEntity.PageLayout,
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type FieldManifest,
|
||||
type NavigationMenuItemManifest,
|
||||
type RoleManifest,
|
||||
type SkillManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export type ValidationResult<T> = {
|
||||
@@ -23,6 +24,7 @@ export type DefinableEntity =
|
||||
| FrontComponentConfig
|
||||
| LogicFunctionConfig
|
||||
| RoleManifest
|
||||
| SkillManifest
|
||||
| ViewConfig
|
||||
| NavigationMenuItemManifest
|
||||
| PageLayoutConfig;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<SkillManifest> = (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 });
|
||||
};
|
||||
+1
@@ -6,6 +6,7 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [
|
||||
'logicFunction',
|
||||
'frontComponent',
|
||||
'role',
|
||||
'skill',
|
||||
'view',
|
||||
'viewField',
|
||||
'viewFieldGroup',
|
||||
|
||||
+15
@@ -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(
|
||||
{
|
||||
|
||||
+27
@@ -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,
|
||||
};
|
||||
};
|
||||
+74
@@ -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<String>,
|
||||
"canAccessAllTools": false,
|
||||
"canBeAssignedToAgents": true,
|
||||
"canBeAssignedToApiKeys": true,
|
||||
"canBeAssignedToUsers": true,
|
||||
"canDestroyAllObjectRecords": false,
|
||||
"canReadAllObjectRecords": false,
|
||||
"canSoftDeleteAllObjectRecords": false,
|
||||
"canUpdateAllObjectRecords": false,
|
||||
"canUpdateAllSettings": false,
|
||||
"createdAt": Any<String>,
|
||||
"description": "A test role",
|
||||
"icon": null,
|
||||
"isEditable": true,
|
||||
"label": "Test Role",
|
||||
"universalIdentifier": Any<String>,
|
||||
"updatedAt": Any<String>,
|
||||
},
|
||||
"metadataName": "role",
|
||||
"type": "create",
|
||||
},
|
||||
{
|
||||
"flatEntity": {
|
||||
"applicationUniversalIdentifier": Any<String>,
|
||||
"content": "# Test Skill
|
||||
|
||||
This is a test skill.",
|
||||
"createdAt": Any<String>,
|
||||
"description": "A skill for testing",
|
||||
"icon": "IconBrain",
|
||||
"isActive": true,
|
||||
"isCustom": false,
|
||||
"label": "Test Skill",
|
||||
"name": "test-skill",
|
||||
"universalIdentifier": Any<String>,
|
||||
"updatedAt": Any<String>,
|
||||
},
|
||||
"metadataName": "skill",
|
||||
"type": "create",
|
||||
},
|
||||
],
|
||||
"applicationUniversalIdentifier": Any<String>,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`syncApplication should sync a skill then update it on second sync 2`] = `
|
||||
{
|
||||
"syncApplication": {
|
||||
"actions": [
|
||||
{
|
||||
"metadataName": "skill",
|
||||
"type": "update",
|
||||
"universalIdentifier": Any<String>,
|
||||
"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<String>,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ const buildBaseManifest = (
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
skills: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+77
-1
@@ -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);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum SyncableEntity {
|
||||
LogicFunction = 'logicFunction',
|
||||
FrontComponent = 'frontComponent',
|
||||
Role = 'role',
|
||||
Skill = 'skill',
|
||||
View = 'view',
|
||||
NavigationMenuItem = 'navigationMenuItem',
|
||||
PageLayout = 'pageLayout',
|
||||
|
||||
@@ -50,6 +50,7 @@ export type {
|
||||
FieldPermissionManifest,
|
||||
RoleManifest,
|
||||
} from './roleManifestType';
|
||||
export type { SkillManifest } from './skillManifestType';
|
||||
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
|
||||
export type {
|
||||
ViewManifestFilterValue,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
|
||||
|
||||
export type SkillManifest = SyncableEntityOptions & {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
};
|
||||
Reference in New Issue
Block a user