Răsfoiți Sursa

refactor(cli): Remove code duplication in the add command (#3855)

Co-authored-by: Housein Abo Shaar <76689341+GogoIsProgramming@users.noreply.github.com>
Housein Abo Shaar 3 luni în urmă
părinte
comite
bd0fae3d2f

+ 84 - 71
packages/cli/e2e/add-command.e2e-spec.ts

@@ -6,32 +6,35 @@
  */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
-import { performAddOperation } from '../src/commands/add/add-operations';
-import { addApiExtensionCommand } from '../src/commands/add/api-extension/add-api-extension';
-import { addCodegenCommand } from '../src/commands/add/codegen/add-codegen';
-import { addEntityCommand } from '../src/commands/add/entity/add-entity';
-import { addJobQueueCommand } from '../src/commands/add/job-queue/add-job-queue';
-import { createNewPluginCommand } from '../src/commands/add/plugin/create-new-plugin';
-import { addServiceCommand } from '../src/commands/add/service/add-service';
-import { addUiExtensionsCommand } from '../src/commands/add/ui-extensions/add-ui-extensions';
+import { addCommand } from '../src/commands/add/add';
+import * as apiExtensionModule from '../src/commands/add/api-extension/add-api-extension';
+import * as codegenModule from '../src/commands/add/codegen/add-codegen';
+import * as entityModule from '../src/commands/add/entity/add-entity';
+import * as jobQueueModule from '../src/commands/add/job-queue/add-job-queue';
+import * as pluginModule from '../src/commands/add/plugin/create-new-plugin';
+import * as serviceModule from '../src/commands/add/service/add-service';
+import * as uiExtensionsModule from '../src/commands/add/ui-extensions/add-ui-extensions';
+
+// Mock clack prompts to prevent interactive prompts during tests
+vi.mock('@clack/prompts', () => ({
+    intro: vi.fn(),
+    outro: vi.fn(),
+    cancel: vi.fn(),
+    isCancel: vi.fn(() => false),
+    select: vi.fn(() => Promise.resolve('no-selection')),
+    spinner: vi.fn(() => ({
+        start: vi.fn(),
+        stop: vi.fn(),
+    })),
+    log: {
+        success: vi.fn(),
+        error: vi.fn(),
+        info: vi.fn(),
+    },
+}));
 
 type Spy = ReturnType<typeof vi.spyOn>;
 
-/**
- * Utility to stub the `run` function of a CliCommand so that the command
- * doesn't actually execute any file-system or project manipulation during the
- * tests. The stub resolves immediately, emulating a successful CLI command.
- */
-function stubRun(cmd: { run: (...args: any[]) => any }): Spy {
-    // Cast to 'any' to avoid over-constraining the generic type parameters that
-    // vitest uses on spyOn, which causes type inference issues in strict mode.
-    // The runtime behaviour (spying on an object method) is what matters for
-    // these tests – the precise compile-time types are not important.
-    return vi
-        .spyOn(cmd as any, 'run')
-        .mockResolvedValue({ project: undefined, modifiedSourceFiles: [] } as any);
-}
-
 let pluginRunSpy: Spy;
 let entityRunSpy: Spy;
 let serviceRunSpy: Spy;
@@ -41,14 +44,20 @@ let apiExtRunSpy: Spy;
 let uiExtRunSpy: Spy;
 
 beforeEach(() => {
-    // Stub all sub-command `run` handlers before every test
-    pluginRunSpy = stubRun(createNewPluginCommand);
-    entityRunSpy = stubRun(addEntityCommand);
-    serviceRunSpy = stubRun(addServiceCommand);
-    jobQueueRunSpy = stubRun(addJobQueueCommand);
-    codegenRunSpy = stubRun(addCodegenCommand);
-    apiExtRunSpy = stubRun(addApiExtensionCommand);
-    uiExtRunSpy = stubRun(addUiExtensionsCommand);
+    // Stub all core functions before every test
+    const defaultReturnValue = { project: undefined as any, modifiedSourceFiles: [] };
+
+    pluginRunSpy = vi.spyOn(pluginModule, 'createNewPlugin').mockResolvedValue(defaultReturnValue as any);
+    entityRunSpy = vi.spyOn(entityModule, 'addEntity').mockResolvedValue(defaultReturnValue as any);
+    serviceRunSpy = vi.spyOn(serviceModule, 'addService').mockResolvedValue(defaultReturnValue as any);
+    jobQueueRunSpy = vi.spyOn(jobQueueModule, 'addJobQueue').mockResolvedValue(defaultReturnValue as any);
+    codegenRunSpy = vi.spyOn(codegenModule, 'addCodegen').mockResolvedValue(defaultReturnValue as any);
+    apiExtRunSpy = vi
+        .spyOn(apiExtensionModule, 'addApiExtension')
+        .mockResolvedValue(defaultReturnValue as any);
+    uiExtRunSpy = vi
+        .spyOn(uiExtensionsModule, 'addUiExtensions')
+        .mockResolvedValue(defaultReturnValue as any);
 });
 
 afterEach(() => {
@@ -56,25 +65,27 @@ afterEach(() => {
 });
 
 describe('Add Command E2E', () => {
-    describe('performAddOperation', () => {
+    describe('addCommand non-interactive mode', () => {
         it('creates a plugin when the "plugin" option is provided', async () => {
-            const result = await performAddOperation({ plugin: 'test-plugin' });
+            await addCommand({ plugin: 'test-plugin' });
 
             expect(pluginRunSpy).toHaveBeenCalledOnce();
             expect(pluginRunSpy).toHaveBeenCalledWith({ name: 'test-plugin', config: undefined });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('test-plugin');
         });
 
         it('throws when the plugin name is empty', async () => {
-            await expect(performAddOperation({ plugin: '   ' } as any)).rejects.toThrow(
-                'Plugin name is required',
-            );
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ plugin: '   ' });
+
             expect(pluginRunSpy).not.toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+
+            exitSpy.mockRestore();
         });
 
         it('adds an entity to the specified plugin', async () => {
-            const result = await performAddOperation({
+            await addCommand({
                 entity: 'MyEntity',
                 selectedPlugin: 'MyPlugin',
             });
@@ -88,20 +99,21 @@ describe('Add Command E2E', () => {
                 customFields: undefined,
                 translatable: undefined,
             });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('MyEntity');
-            expect(result.message).toContain('MyPlugin');
         });
 
         it('fails when adding an entity without specifying a plugin in non-interactive mode', async () => {
-            await expect(performAddOperation({ entity: 'MyEntity' })).rejects.toThrow(
-                'Plugin name is required when running in non-interactive mode',
-            );
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ entity: 'MyEntity' });
+
             expect(entityRunSpy).not.toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+
+            exitSpy.mockRestore();
         });
 
         it('adds a service to the specified plugin', async () => {
-            const result = await performAddOperation({
+            await addCommand({
                 service: 'MyService',
                 selectedPlugin: 'MyPlugin',
             });
@@ -111,8 +123,6 @@ describe('Add Command E2E', () => {
                 serviceName: 'MyService',
                 pluginName: 'MyPlugin',
             });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('MyService');
         });
 
         it('adds a job-queue when required parameters are provided', async () => {
@@ -121,7 +131,7 @@ describe('Add Command E2E', () => {
                 name: 'ReindexJob',
                 selectedService: 'SearchService',
             } as const;
-            const result = await performAddOperation(options);
+            await addCommand(options);
 
             expect(jobQueueRunSpy).toHaveBeenCalledOnce();
             expect(jobQueueRunSpy.mock.calls[0][0]).toMatchObject({
@@ -129,36 +139,35 @@ describe('Add Command E2E', () => {
                 name: 'ReindexJob',
                 selectedService: 'SearchService',
             });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('Job-queue');
         });
 
         it('fails when job-queue parameters are incomplete', async () => {
-            await expect(
-                performAddOperation({ jobQueue: true, name: 'JobWithoutService' } as any),
-            ).rejects.toThrow('Service name is required for job queue');
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ jobQueue: true, name: 'JobWithoutService' } as any);
+
             expect(jobQueueRunSpy).not.toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+
+            exitSpy.mockRestore();
         });
 
         it('adds codegen configuration with boolean flag (interactive plugin selection)', async () => {
-            const result = await performAddOperation({ codegen: true });
+            await addCommand({ codegen: true });
 
             expect(codegenRunSpy).toHaveBeenCalledOnce();
             expect(codegenRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: undefined });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('Codegen');
         });
 
         it('adds codegen configuration to a specific plugin when plugin name is supplied', async () => {
-            const result = await performAddOperation({ codegen: 'MyPlugin' });
+            await addCommand({ codegen: 'MyPlugin' });
 
             expect(codegenRunSpy).toHaveBeenCalledOnce();
             expect(codegenRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: 'MyPlugin' });
-            expect(result.success).toBe(true);
         });
 
         it('adds an API extension scaffold when queryName is provided', async () => {
-            const result = await performAddOperation({
+            await addCommand({
                 apiExtension: 'MyPlugin',
                 queryName: 'myQuery',
             });
@@ -169,33 +178,37 @@ describe('Add Command E2E', () => {
                 queryName: 'myQuery',
                 mutationName: undefined,
             });
-            expect(result.success).toBe(true);
         });
 
         it('fails when neither queryName nor mutationName is provided for API extension', async () => {
-            await expect(performAddOperation({ apiExtension: true } as any)).rejects.toThrow(
-                'At least one of query-name or mutation-name must be specified',
-            );
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ apiExtension: true } as any);
+
             expect(apiExtRunSpy).not.toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+
+            exitSpy.mockRestore();
         });
 
         it('adds UI extensions when the uiExtensions flag is used', async () => {
-            const result = await performAddOperation({ uiExtensions: 'MyPlugin' });
+            await addCommand({ uiExtensions: 'MyPlugin' });
 
             expect(uiExtRunSpy).toHaveBeenCalledOnce();
             expect(uiExtRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: 'MyPlugin' });
-            expect(result.success).toBe(true);
-            expect(result.message).toContain('UI extensions');
         });
 
-        it('returns a failure result when no valid operation is specified', async () => {
-            const result = await performAddOperation({});
+        it('exits with error when no valid operation is specified', async () => {
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
 
-            expect(result.success).toBe(false);
-            expect(result.message).toContain('No valid add operation specified');
+            await addCommand({});
+
+            expect(exitSpy).not.toHaveBeenCalled(); // Empty options triggers interactive mode
             expect(pluginRunSpy).not.toHaveBeenCalled();
             expect(entityRunSpy).not.toHaveBeenCalled();
             expect(serviceRunSpy).not.toHaveBeenCalled();
+
+            exitSpy.mockRestore();
         });
     });
 });

+ 0 - 276
packages/cli/src/commands/add/add-operations.ts

@@ -1,276 +0,0 @@
-import { log } from '@clack/prompts';
-import pc from 'picocolors';
-
-import { addApiExtensionCommand } from './api-extension/add-api-extension';
-import { addCodegenCommand } from './codegen/add-codegen';
-import { addEntityCommand } from './entity/add-entity';
-import { addJobQueueCommand } from './job-queue/add-job-queue';
-import { createNewPluginCommand } from './plugin/create-new-plugin';
-import { addServiceCommand } from './service/add-service';
-import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions';
-
-// The set of mutually-exclusive operations that can be performed in a non-interactive call to the `add` command.
-export interface AddOperationOptions {
-    /** Create a new plugin with the given name */
-    plugin?: string;
-    /** Add a new entity class with the given name */
-    entity?: string;
-    /** Add a new service with the given name */
-    service?: string;
-    /** Add a job-queue handler to the specified plugin */
-    jobQueue?: string | boolean;
-    /** Add GraphQL codegen configuration to the specified plugin */
-    codegen?: string | boolean;
-    /** Add an API extension scaffold to the specified plugin */
-    apiExtension?: string | boolean;
-    /** Add Admin-UI or Storefront UI extensions to the specified plugin */
-    uiExtensions?: string | boolean;
-    /** Specify the path to a custom Vendure config file */
-    config?: string;
-    /** Name for the job queue (used with jobQueue) */
-    name?: string;
-    /** Name for the query (used with apiExtension) */
-    queryName?: string;
-    /** Name for the mutation (used with apiExtension) */
-    mutationName?: string;
-    /** Name of the service to use (used with jobQueue) */
-    selectedService?: string;
-    /** Selected plugin name for entity/service commands */
-    selectedPlugin?: string;
-    /** Add custom fields support to entity */
-    customFields?: boolean;
-    /** Make entity translatable */
-    translatable?: boolean;
-    /** Service type: basic or entity */
-    type?: string;
-    /** Selected entity name for entity service commands */
-    selectedEntity?: string;
-}
-
-export interface AddOperationResult {
-    success: boolean;
-    message: string;
-}
-
-/**
- * Determines which sub-command to execute based on the provided options and
- * delegates the work to that command's `run()` function. The interactive prompts
- * inside the sub-command will only be shown for data that is still missing – so
- * callers can supply as many or as few options as they need.
- */
-export async function performAddOperation(options: AddOperationOptions): Promise<AddOperationResult> {
-    try {
-        // Figure out which flag was set. They are mutually exclusive: the first
-        // truthy option determines the sub-command we run.
-        if (options.plugin) {
-            // Validate that a plugin name was provided
-            if (typeof options.plugin !== 'string' || !options.plugin.trim()) {
-                throw new Error('Plugin name is required. Usage: vendure add -p <plugin-name>');
-            }
-            await createNewPluginCommand.run({ name: options.plugin, config: options.config });
-            return {
-                success: true,
-                message: `Plugin "${options.plugin}" created successfully`,
-            };
-        }
-        if (options.entity) {
-            // Validate that an entity name was provided
-            if (typeof options.entity !== 'string' || !options.entity.trim()) {
-                throw new Error(
-                    'Entity name is required. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
-                );
-            }
-            // Validate that a plugin name was provided for non-interactive mode
-            if (
-                !options.selectedPlugin ||
-                typeof options.selectedPlugin !== 'string' ||
-                !options.selectedPlugin.trim()
-            ) {
-                throw new Error(
-                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
-                );
-            }
-            // Pass the class name and plugin name with additional options
-            await addEntityCommand.run({
-                className: options.entity,
-                isNonInteractive: true,
-                config: options.config,
-                pluginName: options.selectedPlugin,
-                customFields: options.customFields,
-                translatable: options.translatable,
-            });
-            return {
-                success: true,
-                message: `Entity "${options.entity}" added successfully to plugin "${options.selectedPlugin}"`,
-            };
-        }
-        if (options.service) {
-            // Validate that a service name was provided
-            if (typeof options.service !== 'string' || !options.service.trim()) {
-                throw new Error(
-                    'Service name is required. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
-                );
-            }
-            // Validate that a plugin name was provided for non-interactive mode
-            if (
-                !options.selectedPlugin ||
-                typeof options.selectedPlugin !== 'string' ||
-                !options.selectedPlugin.trim()
-            ) {
-                throw new Error(
-                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
-                );
-            }
-            await addServiceCommand.run({
-                serviceName: options.service,
-                isNonInteractive: true,
-                config: options.config,
-                pluginName: options.selectedPlugin,
-                serviceType: options.selectedEntity ? 'entity' : options.type || 'basic',
-                selectedEntityName: options.selectedEntity,
-            });
-            return {
-                success: true,
-                message: `Service "${options.service}" added successfully to plugin "${options.selectedPlugin}"`,
-            };
-        }
-        if (options.jobQueue) {
-            const pluginName = typeof options.jobQueue === 'string' ? options.jobQueue : undefined;
-            // Validate required parameters for job queue
-            if (!options.name || typeof options.name !== 'string' || !options.name.trim()) {
-                throw new Error(
-                    'Job queue name is required. Usage: vendure add -j [plugin-name] --name <job-name>',
-                );
-            }
-            if (
-                !options.selectedService ||
-                typeof options.selectedService !== 'string' ||
-                !options.selectedService.trim()
-            ) {
-                throw new Error(
-                    'Service name is required for job queue. Usage: vendure add -j [plugin-name] --name <job-name> --selected-service <service-name>',
-                );
-            }
-            await addJobQueueCommand.run({
-                isNonInteractive: true,
-                config: options.config,
-                pluginName,
-                name: options.name,
-                selectedService: options.selectedService,
-            });
-            return {
-                success: true,
-                message: 'Job-queue feature added successfully',
-            };
-        }
-        if (options.codegen) {
-            const pluginName = typeof options.codegen === 'string' ? options.codegen : undefined;
-            // For codegen, if a boolean true is passed, plugin selection will be handled interactively
-            // If a string is passed, it should be a valid plugin name
-            if (typeof options.codegen === 'string' && !options.codegen.trim()) {
-                throw new Error(
-                    'Plugin name cannot be empty when specified. Usage: vendure add --codegen [plugin-name]',
-                );
-            }
-            await addCodegenCommand.run({
-                isNonInteractive: true,
-                config: options.config,
-                pluginName,
-            });
-            return {
-                success: true,
-                message: 'Codegen configuration added successfully',
-            };
-        }
-        if (options.apiExtension) {
-            const pluginName = typeof options.apiExtension === 'string' ? options.apiExtension : undefined;
-            // Validate that at least one of queryName or mutationName is provided and not empty
-            const hasValidQueryName =
-                options.queryName && typeof options.queryName === 'string' && options.queryName.trim() !== '';
-            const hasValidMutationName =
-                options.mutationName &&
-                typeof options.mutationName === 'string' &&
-                options.mutationName.trim() !== '';
-
-            if (!hasValidQueryName && !hasValidMutationName) {
-                throw new Error(
-                    'At least one of query-name or mutation-name must be specified as a non-empty string. ' +
-                        'Usage: vendure add -a [plugin-name] --query-name <name> --mutation-name <name>',
-                );
-            }
-
-            // If a string is passed for apiExtension, it should be a valid plugin name
-            if (typeof options.apiExtension === 'string' && !options.apiExtension.trim()) {
-                throw new Error(
-                    'Plugin name cannot be empty when specified. ' +
-                        'Usage: vendure add -a [plugin-name] --query-name <name> --mutation-name <name>',
-                );
-            }
-
-            await addApiExtensionCommand.run({
-                isNonInteractive: true,
-                config: options.config,
-                pluginName,
-                queryName: options.queryName,
-                mutationName: options.mutationName,
-                selectedService: options.selectedService,
-            });
-            return {
-                success: true,
-                message: 'API extension scaffold added successfully',
-            };
-        }
-        if (options.uiExtensions) {
-            const pluginName = typeof options.uiExtensions === 'string' ? options.uiExtensions : undefined;
-            // For UI extensions, if a boolean true is passed, plugin selection will be handled interactively
-            // If a string is passed, it should be a valid plugin name
-            if (typeof options.uiExtensions === 'string' && !options.uiExtensions.trim()) {
-                throw new Error(
-                    'Plugin name cannot be empty when specified. Usage: vendure add --ui-extensions [plugin-name]',
-                );
-            }
-            await addUiExtensionsCommand.run({
-                isNonInteractive: true,
-                config: options.config,
-                pluginName,
-            });
-            return {
-                success: true,
-                message: 'UI extensions added successfully',
-            };
-        }
-
-        return {
-            success: false,
-            message: 'No valid add operation specified',
-        };
-    } catch (error: any) {
-        // Re-throw validation errors so they can be properly handled with stack trace
-        if (
-            error.message.includes('is required') ||
-            error.message.includes('cannot be empty') ||
-            error.message.includes('must be specified')
-        ) {
-            throw error;
-        }
-        // For other errors, log them in a more user-friendly way
-        // For validation errors, show the full error with stack trace
-        if (error.message.includes('Plugin name is required')) {
-            // Extract error message and stack trace
-            const errorMessage = error.message;
-            const stackLines = error.stack.split('\n');
-            const stackTrace = stackLines.slice(1).join('\n'); // Remove first line (error message)
-
-            // Display stack trace first, then colored error message at the end
-            log.error(stackTrace);
-            log.error(''); // Add empty line for better readability
-            log.error(pc.red('Error:') + ' ' + String(errorMessage));
-        } else {
-            log.error(error.message as string);
-            if (error.stack) {
-                log.error(error.stack);
-            }
-        }
-        process.exit(1);
-    }
-}

+ 321 - 0
packages/cli/src/commands/add/add.spec.ts

@@ -0,0 +1,321 @@
+/**
+ * Unit tests for the add command
+ * These tests ensure both interactive and non-interactive modes work correctly
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { addCommand } from './add';
+import * as apiExtensionModule from './api-extension/add-api-extension';
+import * as codegenModule from './codegen/add-codegen';
+import * as entityModule from './entity/add-entity';
+import * as jobQueueModule from './job-queue/add-job-queue';
+import * as pluginModule from './plugin/create-new-plugin';
+import * as serviceModule from './service/add-service';
+import * as uiExtensionsModule from './ui-extensions/add-ui-extensions';
+
+// Mock all the core functions
+vi.mock('./plugin/create-new-plugin', () => ({
+    createNewPlugin: vi.fn(),
+}));
+vi.mock('./entity/add-entity', () => ({
+    addEntity: vi.fn(),
+}));
+vi.mock('./service/add-service', () => ({
+    addService: vi.fn(),
+}));
+vi.mock('./job-queue/add-job-queue', () => ({
+    addJobQueue: vi.fn(),
+}));
+vi.mock('./codegen/add-codegen', () => ({
+    addCodegen: vi.fn(),
+}));
+vi.mock('./api-extension/add-api-extension', () => ({
+    addApiExtension: vi.fn(),
+}));
+vi.mock('./ui-extensions/add-ui-extensions', () => ({
+    addUiExtensions: vi.fn(),
+}));
+
+// Mock clack prompts to prevent interactive prompts during tests
+vi.mock('@clack/prompts', () => ({
+    intro: vi.fn(),
+    outro: vi.fn(),
+    cancel: vi.fn(),
+    isCancel: vi.fn(() => false),
+    select: vi.fn(() => Promise.resolve('no-selection')),
+    spinner: vi.fn(() => ({
+        start: vi.fn(),
+        stop: vi.fn(),
+    })),
+    log: {
+        success: vi.fn(),
+        error: vi.fn(),
+        info: vi.fn(),
+    },
+}));
+
+describe('add command', () => {
+    const mockCreateNewPlugin = vi.mocked(pluginModule.createNewPlugin);
+    const mockAddEntity = vi.mocked(entityModule.addEntity);
+    const mockAddService = vi.mocked(serviceModule.addService);
+    const mockAddJobQueue = vi.mocked(jobQueueModule.addJobQueue);
+    const mockAddCodegen = vi.mocked(codegenModule.addCodegen);
+    const mockAddApiExtension = vi.mocked(apiExtensionModule.addApiExtension);
+    const mockAddUiExtensions = vi.mocked(uiExtensionsModule.addUiExtensions);
+
+    beforeEach(() => {
+        vi.clearAllMocks();
+        // Default to successful operation (all functions return CliCommandReturnVal)
+        const defaultReturnValue = {
+            project: {} as any,
+            modifiedSourceFiles: [],
+        };
+        mockCreateNewPlugin.mockResolvedValue(defaultReturnValue as any);
+        mockAddEntity.mockResolvedValue(defaultReturnValue as any);
+        mockAddService.mockResolvedValue(defaultReturnValue as any);
+        mockAddJobQueue.mockResolvedValue(defaultReturnValue as any);
+        mockAddCodegen.mockResolvedValue(defaultReturnValue as any);
+        mockAddApiExtension.mockResolvedValue(defaultReturnValue as any);
+        mockAddUiExtensions.mockResolvedValue(defaultReturnValue as any);
+    });
+
+    afterEach(() => {
+        vi.restoreAllMocks();
+    });
+
+    describe('non-interactive mode detection', () => {
+        it('detects non-interactive mode when plugin option is provided', async () => {
+            await addCommand({ plugin: 'test-plugin' });
+
+            expect(mockCreateNewPlugin).toHaveBeenCalledWith({ name: 'test-plugin', config: undefined });
+        });
+
+        it('detects non-interactive mode when entity option is provided', async () => {
+            await addCommand({ entity: 'TestEntity', selectedPlugin: 'TestPlugin' });
+
+            expect(mockAddEntity).toHaveBeenCalledWith({
+                className: 'TestEntity',
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'TestPlugin',
+                customFields: undefined,
+                translatable: undefined,
+            });
+        });
+
+        it('detects non-interactive mode when service option is provided', async () => {
+            await addCommand({ service: 'TestService', selectedPlugin: 'TestPlugin' });
+
+            expect(mockAddService).toHaveBeenCalledWith({
+                serviceName: 'TestService',
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'TestPlugin',
+                serviceType: 'basic',
+                selectedEntityName: undefined,
+            });
+        });
+
+        it('detects non-interactive mode when jobQueue option is provided', async () => {
+            await addCommand({
+                jobQueue: 'TestPlugin',
+                name: 'TestJob',
+                selectedService: 'TestService',
+            });
+
+            expect(mockAddJobQueue).toHaveBeenCalledWith({
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'TestPlugin',
+                name: 'TestJob',
+                selectedService: 'TestService',
+            });
+        });
+
+        it('detects non-interactive mode when codegen option is provided', async () => {
+            await addCommand({ codegen: true });
+
+            expect(mockAddCodegen).toHaveBeenCalledWith({
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: undefined,
+            });
+        });
+
+        it('detects non-interactive mode when apiExtension option is provided', async () => {
+            await addCommand({ apiExtension: 'TestPlugin', queryName: 'testQuery' });
+
+            expect(mockAddApiExtension).toHaveBeenCalledWith({
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'TestPlugin',
+                queryName: 'testQuery',
+                mutationName: undefined,
+                selectedService: undefined,
+            });
+        });
+
+        it('detects non-interactive mode when uiExtensions option is provided', async () => {
+            await addCommand({ uiExtensions: 'TestPlugin' });
+
+            expect(mockAddUiExtensions).toHaveBeenCalledWith({
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'TestPlugin',
+            });
+        });
+
+        it('detects non-interactive mode when config option is provided along with other options', async () => {
+            await addCommand({ plugin: 'test-plugin', config: './custom-config.ts' });
+
+            expect(mockCreateNewPlugin).toHaveBeenCalledWith({
+                name: 'test-plugin',
+                config: './custom-config.ts',
+            });
+        });
+
+        it('treats false values as not triggering non-interactive mode', async () => {
+            await addCommand({ codegen: false, uiExtensions: false } as any);
+
+            // Should NOT call any add functions (goes to interactive mode instead)
+            expect(mockCreateNewPlugin).not.toHaveBeenCalled();
+            expect(mockAddCodegen).not.toHaveBeenCalled();
+            expect(mockAddUiExtensions).not.toHaveBeenCalled();
+        });
+    });
+
+    describe('non-interactive mode - success cases', () => {
+        it('logs success message when operation succeeds', async () => {
+            const { log } = await import('@clack/prompts');
+
+            await addCommand({ plugin: 'test-plugin' });
+
+            expect(log.success).toHaveBeenCalledWith('Plugin "test-plugin" created successfully');
+        });
+
+        it('passes through all provided options correctly', async () => {
+            const options = {
+                entity: 'MyEntity',
+                selectedPlugin: 'MyPlugin',
+                customFields: true,
+                translatable: true,
+                config: './config.ts',
+            };
+
+            await addCommand(options);
+
+            expect(mockAddEntity).toHaveBeenCalledWith({
+                className: 'MyEntity',
+                isNonInteractive: true,
+                config: './config.ts',
+                pluginName: 'MyPlugin',
+                customFields: true,
+                translatable: true,
+            });
+        });
+    });
+
+    describe('non-interactive mode - error cases', () => {
+        it('logs error and exits when validation fails', async () => {
+            const { log } = await import('@clack/prompts');
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ plugin: '   ' }); // Empty plugin name
+
+            expect(log.error).toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+            expect(mockCreateNewPlugin).not.toHaveBeenCalled();
+
+            exitSpy.mockRestore();
+        });
+
+        it('logs error with stack trace when exception is thrown', async () => {
+            const { log } = await import('@clack/prompts');
+            const error = new Error('Plugin name is required');
+            error.stack = 'Error: Plugin name is required\n    at someFunction';
+            mockCreateNewPlugin.mockRejectedValue(error);
+
+            const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+            await addCommand({ plugin: 'test' });
+
+            expect(log.error).toHaveBeenCalled();
+            expect(exitSpy).toHaveBeenCalledWith(1);
+
+            exitSpy.mockRestore();
+        });
+    });
+
+    describe('interactive mode detection', () => {
+        it('enters interactive mode when no options are provided', async () => {
+            const { intro } = await import('@clack/prompts');
+
+            await addCommand();
+
+            // Should call intro to start interactive session
+            expect(intro).toHaveBeenCalled();
+            // Should NOT call any add functions
+            expect(mockCreateNewPlugin).not.toHaveBeenCalled();
+            expect(mockAddEntity).not.toHaveBeenCalled();
+        });
+
+        it('enters interactive mode when options object is empty', async () => {
+            const { intro } = await import('@clack/prompts');
+
+            await addCommand({});
+
+            expect(intro).toHaveBeenCalled();
+            expect(mockCreateNewPlugin).not.toHaveBeenCalled();
+        });
+
+        it('enters non-interactive mode when config is provided with operation flag', async () => {
+            // Config combined with an operation triggers non-interactive mode
+            await addCommand({ config: './config.ts', plugin: 'TestPlugin' });
+
+            expect(mockCreateNewPlugin).toHaveBeenCalledWith({
+                name: 'TestPlugin',
+                config: './config.ts',
+            });
+        });
+    });
+
+    describe('option mapping consistency', () => {
+        it('preserves all option properties when routing to functions', async () => {
+            const complexOptions = {
+                service: 'ComplexService',
+                selectedPlugin: 'ComplexPlugin',
+                type: 'entity',
+                selectedEntity: 'Product',
+                config: './custom.config.ts',
+            };
+
+            await addCommand(complexOptions);
+
+            expect(mockAddService).toHaveBeenCalledWith({
+                serviceName: 'ComplexService',
+                isNonInteractive: true,
+                config: './custom.config.ts',
+                pluginName: 'ComplexPlugin',
+                serviceType: 'entity',
+                selectedEntityName: 'Product',
+            });
+        });
+
+        it('calls functions with correct parameter mapping for complex options', async () => {
+            await addCommand({
+                jobQueue: true,
+                name: 'MyJob',
+                selectedService: 'MyService',
+                config: './config.ts',
+            });
+
+            expect(mockAddJobQueue).toHaveBeenCalledWith({
+                isNonInteractive: true,
+                config: './config.ts',
+                pluginName: undefined,
+                name: 'MyJob',
+                selectedService: 'MyService',
+            });
+        });
+    });
+});

+ 217 - 31
packages/cli/src/commands/add/add.ts

@@ -2,40 +2,224 @@ import { cancel, intro, isCancel, log, outro, select, spinner } from '@clack/pro
 import pc from 'picocolors';
 
 import { Messages } from '../../constants';
-import { CliCommand } from '../../shared/cli-command';
 import { pauseForPromptDisplay, withInteractiveTimeout } from '../../utilities/utils';
+import { cliCommands } from '../command-declarations';
 
-import { AddOperationOptions, performAddOperation } from './add-operations';
-import { addApiExtensionCommand } from './api-extension/add-api-extension';
-import { addCodegenCommand } from './codegen/add-codegen';
-import { addEntityCommand } from './entity/add-entity';
-import { addJobQueueCommand } from './job-queue/add-job-queue';
-import { createNewPluginCommand } from './plugin/create-new-plugin';
-import { addServiceCommand } from './service/add-service';
-import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions';
+import { addApiExtension } from './api-extension/add-api-extension';
+import { addCodegen } from './codegen/add-codegen';
+import { addEntity } from './entity/add-entity';
+import { addJobQueue } from './job-queue/add-job-queue';
+import { createNewPlugin } from './plugin/create-new-plugin';
+import { addService } from './service/add-service';
+import { addUiExtensions } from './ui-extensions/add-ui-extensions';
 
 const cancelledMessage = 'Add feature cancelled.';
 
-export interface AddOptions extends AddOperationOptions {}
+export interface AddOptions {
+    /** Create a new plugin with the given name */
+    plugin?: string;
+    /** Add a new entity class with the given name */
+    entity?: string;
+    /** Add a new service with the given name */
+    service?: string;
+    /** Add a job-queue handler to the specified plugin */
+    jobQueue?: string | boolean;
+    /** Add GraphQL codegen configuration to the specified plugin */
+    codegen?: string | boolean;
+    /** Add an API extension scaffold to the specified plugin */
+    apiExtension?: string | boolean;
+    /** Add Admin-UI or Storefront UI extensions to the specified plugin */
+    uiExtensions?: string | boolean;
+    /** Specify the path to a custom Vendure config file */
+    config?: string;
+    /** Name for the job queue (used with jobQueue) */
+    name?: string;
+    /** Name for the query (used with apiExtension) */
+    queryName?: string;
+    /** Name for the mutation (used with apiExtension) */
+    mutationName?: string;
+    /** Name of the service to use (used with jobQueue) */
+    selectedService?: string;
+    /** Selected plugin name for entity/service commands */
+    selectedPlugin?: string;
+    /** Add custom fields support to entity */
+    customFields?: boolean;
+    /** Make entity translatable */
+    translatable?: boolean;
+    /** Service type: basic or entity */
+    type?: string;
+    /** Selected entity name for entity service commands */
+    selectedEntity?: string;
+}
 
 export async function addCommand(options?: AddOptions) {
     // If any non-interactive option is supplied, we switch to the non-interactive path
     const nonInteractive = options && Object.values(options).some(v => v !== undefined && v !== false);
 
     if (nonInteractive) {
-        await handleNonInteractiveMode(options as AddOperationOptions);
+        await handleNonInteractiveMode(options);
     } else {
         await handleInteractiveMode();
     }
 }
 
-async function handleNonInteractiveMode(options: AddOperationOptions) {
+async function handleNonInteractiveMode(options: AddOptions) {
     try {
-        const result = await performAddOperation(options);
-        if (result.success) {
-            log.success(result.message);
+        // Route to the appropriate function based on which flag was set
+        if (options.plugin) {
+            // Validate that a plugin name was provided
+            if (typeof options.plugin !== 'string' || !options.plugin.trim()) {
+                throw new Error('Plugin name is required. Usage: vendure add -p <plugin-name>');
+            }
+            await createNewPlugin({ name: options.plugin, config: options.config });
+            log.success(`Plugin "${options.plugin}" created successfully`);
+        } else if (options.entity) {
+            // Validate that an entity name was provided
+            if (typeof options.entity !== 'string' || !options.entity.trim()) {
+                throw new Error(
+                    'Entity name is required. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
+                );
+            }
+            // Validate that a plugin name was provided for non-interactive mode
+            if (
+                !options.selectedPlugin ||
+                typeof options.selectedPlugin !== 'string' ||
+                !options.selectedPlugin.trim()
+            ) {
+                throw new Error(
+                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
+                );
+            }
+            await addEntity({
+                className: options.entity,
+                isNonInteractive: true,
+                config: options.config,
+                pluginName: options.selectedPlugin,
+                customFields: options.customFields,
+                translatable: options.translatable,
+            });
+            log.success(
+                `Entity "${options.entity}" added successfully to plugin "${options.selectedPlugin}"`,
+            );
+        } else if (options.service) {
+            // Validate that a service name was provided
+            if (typeof options.service !== 'string' || !options.service.trim()) {
+                throw new Error(
+                    'Service name is required. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
+                );
+            }
+            // Validate that a plugin name was provided for non-interactive mode
+            if (
+                !options.selectedPlugin ||
+                typeof options.selectedPlugin !== 'string' ||
+                !options.selectedPlugin.trim()
+            ) {
+                throw new Error(
+                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
+                );
+            }
+            await addService({
+                serviceName: options.service,
+                isNonInteractive: true,
+                config: options.config,
+                pluginName: options.selectedPlugin,
+                serviceType: options.selectedEntity ? 'entity' : options.type || 'basic',
+                selectedEntityName: options.selectedEntity,
+            });
+            log.success(
+                `Service "${options.service}" added successfully to plugin "${options.selectedPlugin}"`,
+            );
+        } else if (options.jobQueue) {
+            const pluginName = typeof options.jobQueue === 'string' ? options.jobQueue : undefined;
+            // Validate required parameters for job queue
+            if (!options.name || typeof options.name !== 'string' || !options.name.trim()) {
+                throw new Error(
+                    'Job queue name is required. Usage: vendure add -j [plugin-name] --name <job-name>',
+                );
+            }
+            if (
+                !options.selectedService ||
+                typeof options.selectedService !== 'string' ||
+                !options.selectedService.trim()
+            ) {
+                throw new Error(
+                    'Service name is required for job queue. Usage: vendure add -j [plugin-name] --name <job-name> --selected-service <service-name>',
+                );
+            }
+            await addJobQueue({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+                name: options.name,
+                selectedService: options.selectedService,
+            });
+            log.success('Job-queue feature added successfully');
+        } else if (options.codegen) {
+            const pluginName = typeof options.codegen === 'string' ? options.codegen : undefined;
+            // For codegen, if a boolean true is passed, plugin selection will be handled interactively
+            // If a string is passed, it should be a valid plugin name
+            if (typeof options.codegen === 'string' && !options.codegen.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. Usage: vendure add --codegen [plugin-name]',
+                );
+            }
+            await addCodegen({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+            });
+            log.success('Codegen configuration added successfully');
+        } else if (options.apiExtension) {
+            const pluginName = typeof options.apiExtension === 'string' ? options.apiExtension : undefined;
+            // Validate that at least one of queryName or mutationName is provided and not empty
+            const hasValidQueryName =
+                options.queryName && typeof options.queryName === 'string' && options.queryName.trim() !== '';
+            const hasValidMutationName =
+                options.mutationName &&
+                typeof options.mutationName === 'string' &&
+                options.mutationName.trim() !== '';
+
+            if (!hasValidQueryName && !hasValidMutationName) {
+                throw new Error(
+                    'At least one of query-name or mutation-name must be specified as a non-empty string. ' +
+                        'Usage: vendure add -a [plugin-name] --query-name <name> --mutation-name <name>',
+                );
+            }
+
+            // If a string is passed for apiExtension, it should be a valid plugin name
+            if (typeof options.apiExtension === 'string' && !options.apiExtension.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. ' +
+                        'Usage: vendure add -a [plugin-name] --query-name <name> --mutation-name <name>',
+                );
+            }
+
+            await addApiExtension({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+                queryName: options.queryName,
+                mutationName: options.mutationName,
+                selectedService: options.selectedService,
+            });
+            log.success('API extension scaffold added successfully');
+        } else if (options.uiExtensions) {
+            const pluginName = typeof options.uiExtensions === 'string' ? options.uiExtensions : undefined;
+            // For UI extensions, if a boolean true is passed, plugin selection will be handled interactively
+            // If a string is passed, it should be a valid plugin name
+            if (typeof options.uiExtensions === 'string' && !options.uiExtensions.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. Usage: vendure add --ui-extensions [plugin-name]',
+                );
+            }
+            await addUiExtensions({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+            });
+            log.success('UI extensions added successfully');
         } else {
-            log.error(result.message);
+            log.error('No valid add operation specified');
             process.exit(1);
         }
     } catch (e: any) {
@@ -64,22 +248,24 @@ async function handleInteractiveMode() {
     // eslint-disable-next-line no-console
     console.log(`\n`);
     intro(pc.blue("✨ Let's add a new feature to your Vendure project!"));
-    const addCommands: Array<CliCommand<any>> = [
-        createNewPluginCommand,
-        addEntityCommand,
-        addServiceCommand,
-        addApiExtensionCommand,
-        addJobQueueCommand,
-        addUiExtensionsCommand,
-        addCodegenCommand,
-    ];
+
+    // Derive interactive options from command declarations (single source of truth)
+    const addCommandDef = cliCommands.find(cmd => cmd.name === 'add');
+    const addOptions =
+        addCommandDef?.options
+            ?.filter(opt => opt.interactiveId && opt.interactiveFn)
+            .map(opt => ({
+                value: opt.interactiveId as string,
+                label: `${pc.blue(opt.interactiveCategory as string)} ${opt.description}`,
+                fn: opt.interactiveFn as () => Promise<any>,
+            })) ?? [];
 
     const featureType = await withInteractiveTimeout(async () => {
         return await select({
             message: 'Which feature would you like to add?',
-            options: addCommands.map(c => ({
-                value: c.id,
-                label: `${pc.blue(`${c.category}`)} ${c.description}`,
+            options: addOptions.map(opt => ({
+                value: opt.value,
+                label: opt.label,
             })),
         });
     });
@@ -89,11 +275,11 @@ async function handleInteractiveMode() {
         process.exit(0);
     }
     try {
-        const command = addCommands.find(c => c.id === featureType);
-        if (!command) {
+        const selectedOption = addOptions.find(opt => opt.value === featureType);
+        if (!selectedOption) {
             throw new Error(`Could not find command with id "${featureType as string}"`);
         }
-        const { modifiedSourceFiles, project } = await command.run();
+        const { modifiedSourceFiles, project } = await selectedOption.fn();
 
         if (modifiedSourceFiles.length) {
             const importsSpinner = spinner();

+ 1 - 1
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -46,7 +46,7 @@ export const addApiExtensionCommand = new CliCommand({
     run: options => addApiExtension(options),
 });
 
-async function addApiExtension(
+export async function addApiExtension(
     options?: AddApiExtensionOptions,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = options?.plugin;

+ 1 - 1
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -26,7 +26,7 @@ export const addCodegenCommand = new CliCommand({
     run: addCodegen,
 });
 
-async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturnVal> {
+export async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturnVal> {
     const providedVendurePlugin = options?.plugin;
     const { project } = await analyzeProject({
         providedVendurePlugin,

+ 1 - 1
packages/cli/src/commands/add/entity/add-entity.ts

@@ -38,7 +38,7 @@ export const addEntityCommand = new CliCommand({
     run: options => addEntity(options),
 });
 
-async function addEntity(
+export async function addEntity(
     options?: Partial<AddEntityOptions>,
 ): Promise<CliCommandReturnVal<{ entityRef: EntityRef }>> {
     const providedVendurePlugin = options?.plugin;

+ 1 - 1
packages/cli/src/commands/add/job-queue/add-job-queue.ts

@@ -28,7 +28,7 @@ export const addJobQueueCommand = new CliCommand({
     run: options => addJobQueue(options),
 });
 
-async function addJobQueue(
+export async function addJobQueue(
     options?: AddJobQueueOptions,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = options?.plugin;

+ 1 - 1
packages/cli/src/commands/add/service/add-service.ts

@@ -39,7 +39,7 @@ export const addServiceCommand = new CliCommand({
     run: options => addService(options),
 });
 
-async function addService(
+export async function addService(
     providedOptions?: Partial<AddServiceOptions>,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = providedOptions?.plugin;

+ 3 - 3
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -7,7 +7,7 @@ import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
-import { createFile, getRelativeImportPath, getPluginClasses } from '../../../utilities/ast-utils';
+import { createFile, getPluginClasses, getRelativeImportPath } from '../../../utilities/ast-utils';
 
 import { addUiExtensionStaticProp } from './codemods/add-ui-extension-static-prop/add-ui-extension-static-prop';
 import { updateAdminUiPluginInit } from './codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init';
@@ -26,7 +26,7 @@ export const addUiExtensionsCommand = new CliCommand<AddUiExtensionsOptions>({
     run: options => addUiExtensions(options),
 });
 
-async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCommandReturnVal> {
+export async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCommandReturnVal> {
     const providedVendurePlugin = options?.plugin;
     const { project } = await analyzeProject({ providedVendurePlugin, config: options?.config });
 
@@ -45,7 +45,7 @@ async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCom
             const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
             throw new Error(
                 `Plugin "${options.pluginName}" not found. Available plugins:\n` +
-                availablePlugins.map(name => `  - ${name as string}`).join('\n')
+                    availablePlugins.map(name => `  - ${name as string}`).join('\n'),
             );
         }
 

+ 35 - 6
packages/cli/src/commands/command-declarations.ts

@@ -1,5 +1,13 @@
 import { CliCommandDefinition } from '../shared/cli-command-definition';
 
+import { addApiExtension } from './add/api-extension/add-api-extension';
+import { addCodegen } from './add/codegen/add-codegen';
+import { addEntity } from './add/entity/add-entity';
+import { addJobQueue } from './add/job-queue/add-job-queue';
+import { createNewPlugin } from './add/plugin/create-new-plugin';
+import { addService } from './add/service/add-service';
+import { addUiExtensions } from './add/ui-extensions/add-ui-extensions';
+
 export const cliCommands: CliCommandDefinition[] = [
     {
         name: 'add',
@@ -15,12 +23,18 @@ export const cliCommands: CliCommandDefinition[] = [
                 long: '--plugin <name>',
                 description: 'Create a new plugin with the specified name',
                 required: false,
+                interactiveId: 'create-new-plugin',
+                interactiveCategory: 'Plugin',
+                interactiveFn: createNewPlugin,
             },
             {
                 short: '-e',
                 long: '--entity <name>',
-                description: 'Add a new entity with the specified class name',
+                description: 'Add a new entity to a plugin',
                 required: false,
+                interactiveId: 'add-entity',
+                interactiveCategory: 'Plugin: Entity',
+                interactiveFn: addEntity,
                 subOptions: [
                     {
                         long: '--selected-plugin <name>',
@@ -42,8 +56,11 @@ export const cliCommands: CliCommandDefinition[] = [
             {
                 short: '-s',
                 long: '--service <name>',
-                description: 'Add a new service with the specified class name',
+                description: 'Add a new service to a plugin',
                 required: false,
+                interactiveId: 'add-service',
+                interactiveCategory: 'Plugin: Service',
+                interactiveFn: addService,
                 subOptions: [
                     {
                         long: '--selected-plugin <name>',
@@ -66,8 +83,11 @@ export const cliCommands: CliCommandDefinition[] = [
             {
                 short: '-j',
                 long: '--job-queue [plugin]',
-                description: 'Add job-queue support to the specified plugin',
+                description: 'Add job queue support to a plugin',
                 required: false,
+                interactiveId: 'add-job-queue',
+                interactiveCategory: 'Plugin: Job Queue',
+                interactiveFn: addJobQueue,
                 subOptions: [
                     {
                         long: '--name <name>',
@@ -84,14 +104,20 @@ export const cliCommands: CliCommandDefinition[] = [
             {
                 short: '-c',
                 long: '--codegen [plugin]',
-                description: 'Add GraphQL codegen configuration to the specified plugin',
+                description: 'Set up GraphQL code generation',
                 required: false,
+                interactiveId: 'add-codegen',
+                interactiveCategory: 'Project: Codegen',
+                interactiveFn: addCodegen,
             },
             {
                 short: '-a',
                 long: '--api-extension [plugin]',
-                description: 'Add an API extension scaffold to the specified plugin',
+                description: 'Add an API extension to a plugin',
                 required: false,
+                interactiveId: 'add-api-extension',
+                interactiveCategory: 'Plugin: API',
+                interactiveFn: addApiExtension,
                 subOptions: [
                     {
                         long: '--query-name <name>',
@@ -113,8 +139,11 @@ export const cliCommands: CliCommandDefinition[] = [
             {
                 short: '-u',
                 long: '--ui-extensions [plugin]',
-                description: 'Add Admin UI extensions setup to the specified plugin',
+                description: 'Add UI extensions to a plugin',
                 required: false,
+                interactiveId: 'add-ui-extensions',
+                interactiveCategory: 'Plugin: UI',
+                interactiveFn: addUiExtensions,
             },
         ],
         action: async options => {

+ 4 - 0
packages/cli/src/shared/cli-command-definition.ts

@@ -5,6 +5,10 @@ export interface CliCommandOption {
     required?: boolean;
     defaultValue?: any;
     subOptions?: CliCommandOption[]; // Options that are only valid when this option is used
+    // Interactive mode metadata
+    interactiveId?: string; // ID for interactive selection (e.g., 'add-entity')
+    interactiveCategory?: string; // Category label (e.g., 'Plugin: Entity')
+    interactiveFn?: () => Promise<any>; // Function to execute in interactive mode
 }
 
 export interface CliCommandDefinition {