1571 extensibility twenty cli add command to create base entities like object agent or serverless (#14722)

Remove default agent when init new app

add command to create a new entity (object or agent)



https://github.com/user-attachments/assets/b8f28281-2b40-4eaa-a958-55d2dd5183c8
This commit is contained in:
martmull
2025-09-25 16:43:26 +02:00
committed by GitHub
parent 1d743359ba
commit 9b3e2bfb45
5 changed files with 191 additions and 30 deletions
@@ -0,0 +1,147 @@
import chalk from 'chalk';
import { resolveAppPath } from '../utils/app-path-resolver';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import { v4 } from 'uuid';
import path from 'path';
import { getSchemaUrls } from '../utils/schema-validator';
import { writeJsoncFile } from '../utils/jsonc-parser';
type SyncableEntity = 'agent' | 'object';
const getFolderName = (entity: SyncableEntity) => {
switch (entity) {
case 'agent':
return 'agents';
case 'object':
return 'objects';
default:
throw new Error(`Unknown entity type: ${entity}`);
}
};
export class AppAddCommand {
async execute(options: { path?: string }): Promise<void> {
const entity = await this.getEntity();
try {
const appPath = await resolveAppPath(options.path);
const appExists = await fs.pathExists(appPath);
if (!appExists) {
console.error(chalk.red('App does not exist'));
process.exit(1);
}
const entityName = await this.getEntityName(entity);
const entityData = await this.getEntityToCreateData(entity, entityName);
const folderName = getFolderName(entity);
const entitiesDir = path.join(appPath, folderName);
await fs.ensureDir(entitiesDir);
const entityPath = path.join(entitiesDir, `${entityName}.jsonc`);
await writeJsoncFile(entityPath, entityData);
} catch (error) {
console.error(
chalk.red(`Add new ${entity} failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getEntity() {
const { entity } = await inquirer.prompt([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: ['agent', 'object'],
},
]);
return entity as SyncableEntity;
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must contain only lowercase letters, numbers, and hyphens';
}
return true;
},
},
]);
return name;
}
private async getEntityToCreateData(
entity: SyncableEntity,
entityName: string,
) {
const schemas = getSchemaUrls();
const entityToCreateData: Record<string, string> = {
$schema: schemas[entity],
standardId: v4(),
};
const schemasDir = path.join(__dirname, '../../schemas');
const schemaPath = path.join(schemasDir, `${entity}.schema.json`);
const schema = await fs.readJson(schemaPath);
const requiredFields = schema.required;
for (const requiredField of requiredFields) {
if (requiredField === 'name') {
entityToCreateData.name = entityName;
continue;
}
if (Object.keys(entityToCreateData).includes(requiredField)) {
continue;
}
const answer = await inquirer.prompt([
{
type: 'input',
name: requiredField,
message: `Enter a ${requiredField} for your new ${entity}:`,
default: '',
validate: (input) => {
try {
return input.length > 0;
} catch {
return 'Please enter non empty string';
}
},
},
]);
entityToCreateData[requiredField] = answer[requiredField];
}
return entityToCreateData;
}
}
@@ -3,7 +3,6 @@ import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import * as path from 'path';
import {
createAgentManifest,
createBasePackageJson,
createGitignoreContent,
createReadmeContent,
@@ -13,14 +12,15 @@ import { writeJsoncFile } from '../utils/jsonc-parser';
export class AppInitCommand {
async execute(options: { path?: string; name?: string }): Promise<void> {
try {
const appName = await this.getAppName(options.name);
const appDir = this.determineAppDirectory(options.path, appName);
const { name, description } = await this.getAppInfos(options.name);
const appDir = this.determineAppDirectory(name, options.path);
await this.validateDirectory(appDir);
this.logCreationInfo(appDir, appName);
this.logCreationInfo(appDir, name);
await this.createAppStructure(appDir, appName);
await this.createAppStructure(appDir, name, description);
this.logSuccess(appDir);
} catch (error) {
@@ -32,15 +32,17 @@ export class AppInitCommand {
}
}
private async getAppName(providedName?: string): Promise<string> {
private async getAppInfos(
providedName?: string,
): Promise<{ name: string; description: string }> {
if (providedName) {
return providedName;
return { name: providedName, description: '' };
}
const nameAnswer = await inquirer.prompt([
return inquirer.prompt([
{
type: 'input',
name: 'appName',
name: 'name',
message: 'Application name:',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
@@ -49,17 +51,21 @@ export class AppInitCommand {
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
default: '',
},
]);
return nameAnswer.appName;
}
private determineAppDirectory(
appName: string,
providedPath?: string,
appName?: string,
): string {
if (providedPath) {
return path.resolve(providedPath);
return path.resolve(providedPath, appName);
}
return path.join(process.cwd(), appName!);
@@ -86,24 +92,15 @@ export class AppInitCommand {
private async createAppStructure(
appDir: string,
appName: string,
description: string,
): Promise<void> {
await fs.ensureDir(appDir);
// Create agents directory
const agentsDir = path.join(appDir, 'agents');
await fs.ensureDir(agentsDir);
// Create main basePackageJson with agent references
const basePackageJson = createBasePackageJson(appName);
const basePackageJson = createBasePackageJson(appName, description);
const basePackageJsonPath = path.join(appDir, 'package.json');
await writeJsoncFile(basePackageJsonPath, basePackageJson);
// Create agent basePackageJson file
const agentManifest = createAgentManifest(appName);
const agentFileName = `${appName}-agent`;
const agentPath = path.join(agentsDir, `${agentFileName}.jsonc`);
await writeJsoncFile(agentPath, agentManifest);
// Create README
const readmeContent = createReadmeContent(appName, appDir);
await fs.writeFile(path.join(appDir, 'README.md'), readmeContent);
@@ -2,11 +2,13 @@ import { Command } from 'commander';
import { AppSyncCommand } from './app-sync.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppAddCommand } from './app-add.command';
export class AppCommand {
private devCommand = new AppDevCommand();
private syncCommand = new AppSyncCommand();
private initCommand = new AppInitCommand();
private addCommand = new AppAddCommand();
getCommand(): Command {
const appCommand = new Command('app');
@@ -44,6 +46,17 @@ export class AppCommand {
await this.initCommand.execute(options);
});
appCommand
.command('add')
.description('Add a new entity to your application')
.option(
'-p, --path <path>',
'Application directory path (auto-detected if not specified)',
)
.action(async (options) => {
await this.addCommand.execute(options);
});
return appCommand;
}
}
@@ -13,7 +13,8 @@ describe('app-template', () => {
describe('createBasePackageJson', () => {
it('should create a valid app package.json with correct structure', () => {
const appName = 'my-test-app';
const basePackageJson = createBasePackageJson(appName);
const description = 'A Twenty application for my-test-app';
const basePackageJson = createBasePackageJson(appName, description);
expect(basePackageJson).toEqual({
$schema:
@@ -27,7 +28,7 @@ describe('app-template', () => {
it('should handle single word app names', () => {
const appName = 'calculator';
const basePackageJson = createBasePackageJson(appName);
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('Calculator');
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
@@ -35,14 +36,14 @@ describe('app-template', () => {
it('should handle kebab-case app names correctly', () => {
const appName = 'user-management-system';
const basePackageJson = createBasePackageJson(appName);
const basePackageJson = createBasePackageJson(appName, '');
expect(basePackageJson.label).toBe('User Management System');
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
});
it('should generate unique standardIds', () => {
const basePackageJson = createBasePackageJson('test-app');
const basePackageJson = createBasePackageJson('test-app', '');
expect(basePackageJson.standardId).toBeDefined();
expect(typeof basePackageJson.standardId).toBe('string');
@@ -2,7 +2,10 @@ import { randomUUID } from 'crypto';
import { AgentManifest, PackageJson } from '../types/config.types';
import { getSchemaUrls } from './schema-validator';
export const createBasePackageJson = (appName: string): PackageJson => {
export const createBasePackageJson = (
appName: string,
description: string,
): PackageJson => {
const schemas = getSchemaUrls();
return {
@@ -12,7 +15,7 @@ export const createBasePackageJson = (appName: string): PackageJson => {
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
description: `A Twenty application for ${appName}`,
description,
version: '0.0.1',
};
};