diff --git a/README.md b/README.md index 9a8d0f94..216cd605 100644 --- a/README.md +++ b/README.md @@ -541,11 +541,13 @@ USAGE $ aio app init [PATH] [-v] [--version] [--install] [-y] [--login] [-e ... | -t ... | --repo ] [--standalone-app | | ] [--template-options ] [-o | -i | ] [-p | | ] [-w | | ] [--confirm-new-workspace] [--use-jwt] [--github-pat ] [--linter none|basic|adobe-recommended] + [-c] ARGUMENTS [PATH] [default: .] Path to the app directory FLAGS + -c, --chat Use AI chat mode for natural language template recommendations -e, --extension=... Extension point(s) to implement -i, --import= Import an Adobe I/O Developer Console configuration file -o, --org= Specify the Adobe Developer Console Org to init from (orgId, or orgCode) diff --git a/src/commands/app/init.js b/src/commands/app/init.js index 5a525596..623cdcd5 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -24,6 +24,7 @@ const { importConsoleConfig } = require('../../lib/import') const { loadAndValidateConfigFile } = require('../../lib/import-helper') const { Octokit } = require('@octokit/rest') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:init', { provider: 'debug' }) +const { getAIRecommendation } = require('../../lib/template-recommendation') const DEFAULT_WORKSPACE = 'Stage' @@ -107,9 +108,14 @@ class InitCommand extends TemplatesCommand { await this.withQuickstart(flags.repo, flags['github-pat']) } else { // 2. prompt for templates to be installed - - // TODO: Modify this to be a prompt for natural language here -mg - const templates = await this.getTemplatesForFlags(flags) + let templates + if (flags.chat) { + // Use AI-powered natural language recommendations + templates = await this.getTemplatesWithAI(flags) + } else { + // Use traditional template selection table + templates = await this.getTemplatesForFlags(flags) + } // If no templates selected, init a standalone app if (templates.length <= 0) { flags['standalone-app'] = true @@ -154,9 +160,13 @@ class InitCommand extends TemplatesCommand { let templates if (!flags.repo) { // 5. get list of templates to install - - // TODO: Modify this to be a prompt for natural language here -mg - templates = await this.getTemplatesForFlags(flags, orgSupportedServices) + if (flags.chat) { + // Use AI-powered natural language recommendations + templates = await this.getTemplatesWithAI(flags, orgSupportedServices) + } else { + // Use traditional template selection table + templates = await this.getTemplatesForFlags(flags, orgSupportedServices) + } // If no templates selected, init a standalone app if (templates.length <= 0) { flags['standalone-app'] = true @@ -187,6 +197,69 @@ class InitCommand extends TemplatesCommand { this.log(chalk.blue(chalk.bold(`Project initialized for Workspace ${workspace.name}, you can run 'aio app use -w ' to switch workspace.`))) } + async getTemplatesWithAI (flags, orgSupportedServices = null) { + // Step 1: Ask user to describe their needs in natural language + const { userPrompt } = await inquirer.prompt([ + { + type: 'input', + name: 'userPrompt', + message: 'Describe what you want to build (e.g., "I need a CRUD API" or "I want an event-driven app"):', + validate: (input) => { + if (!input || input.trim() === '') { + return 'Please provide a description of what you want to build.' + } + return true + } + } + ]) + + const spinner = ora() + spinner.start(`Analyzing your request: "${userPrompt}"`) + try { + // Step 2: Call backend API via lib + const template = await getAIRecommendation(userPrompt) + + // Step 3: No template was returned + if (!template || !template.name) { + spinner.stop() + this.log(chalk.cyan('\n💡 AI could not find a matching template. Please explore templates from the options below:\n')) + return this.getTemplatesForFlags(flags, orgSupportedServices) + } + + // Step 4: Display AI recommendation + spinner.succeed('Found matching template!') + this.log(chalk.bold('\n🤖 AI Recommendation:')) + this.log(chalk.dim(` Based on "${userPrompt}"\n`)) + this.log(` ${chalk.bold(template.name)}`) + if (template.description) { + this.log(` ${chalk.dim(template.description)}`) + } + this.log('') // Empty line + + // Step 5: Confirm with user + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Do you want to install this recommended template?', + default: false + } + ]) + + if (confirm) { + return [template.name] + } else { + this.log(chalk.cyan('\n💡 Please explore templates from the options below:\n')) + return this.getTemplatesForFlags(flags, orgSupportedServices) + } + } catch (error) { + spinner.stop() + aioLogger.error('AI API error:', error) + this.log(chalk.cyan('\n💡 AI recommendation unavailable. Please explore templates from the options below:\n')) + return this.getTemplatesForFlags(flags, orgSupportedServices) + } + } + async getTemplatesForFlags (flags, orgSupportedServices = null) { if (flags.template) { return flags.template @@ -511,6 +584,12 @@ InitCommand.flags = { description: 'Specify the linter to use for the project', options: ['none', 'basic', 'adobe-recommended'], default: 'basic' + }), + chat: Flags.boolean({ + description: 'Use AI chat mode for natural language template recommendations', + char: 'c', + default: false, + exclusive: ['repo', 'template', 'import'] }) } diff --git a/src/lib/defaults.js b/src/lib/defaults.js index c24d5440..45f3ec87 100644 --- a/src/lib/defaults.js +++ b/src/lib/defaults.js @@ -11,6 +11,14 @@ governing permissions and limitations under the License. // defaults & constants +const { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') + +// Template recommendation API endpoints (same pattern as aio-lib-state) +const TEMPLATE_RECOMMENDATION_API_ENDPOINTS = { + [PROD_ENV]: 'https://development-918-aiappinit.adobeioruntime.net/api/v1/web/recommend-api/recommend-template', + [STAGE_ENV]: 'https://development-918-aiappinit-stage.adobeioruntime.net/api/v1/web/recommend-api/recommend-template' +} + module.exports = { defaultAppHostname: 'adobeio-static.net', stageAppHostname: 'dev.runtime.adobe.io', @@ -40,5 +48,7 @@ module.exports = { EXTENSIONS_CONFIG_KEY: 'extensions', // Adding tracking file constants LAST_BUILT_ACTIONS_FILENAME: 'last-built-actions.json', - LAST_DEPLOYED_ACTIONS_FILENAME: 'last-deployed-actions.json' + LAST_DEPLOYED_ACTIONS_FILENAME: 'last-deployed-actions.json', + // Template recommendation API endpoints + TEMPLATE_RECOMMENDATION_API_ENDPOINTS } diff --git a/src/lib/template-recommendation.js b/src/lib/template-recommendation.js new file mode 100644 index 00000000..3ffd848f --- /dev/null +++ b/src/lib/template-recommendation.js @@ -0,0 +1,55 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:template-recommendation', { provider: 'debug' }) +const { TEMPLATE_RECOMMENDATION_API_ENDPOINTS } = require('./defaults') +const { getCliEnv } = require('@adobe/aio-lib-env') + +/** + * Calls the template recommendation API to get AI-based template suggestions + * @param {string} prompt - User's natural language description of what they want to build + * @param {string} [apiUrl] - Optional API URL (defaults to env var TEMPLATE_RECOMMENDATION_API or environment-based URL) + * @returns {Promise} Template recommendation from the API + * @throws {Error} If API call fails + */ +async function getAIRecommendation (prompt, apiUrl) { + // Select URL based on environment (same pattern as aio-lib-state) + const env = getCliEnv() + const url = apiUrl || process.env.TEMPLATE_RECOMMENDATION_API || TEMPLATE_RECOMMENDATION_API_ENDPOINTS[env] + aioLogger.debug(`Calling template recommendation API: ${url} (env: ${env})`) + aioLogger.debug(`Prompt: ${prompt}`) + + const payload = { prompt } + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + + const response = await fetch(url, options) + + if (!response.ok) { + const errorText = await response.text() + aioLogger.error(`API returned status ${response.status}: ${errorText}`) + throw new Error(`API returned status ${response.status}`) + } + + const data = await response.json() + aioLogger.debug(`API response: ${JSON.stringify(data)}`) + return data.body || data +} + +module.exports = { + getAIRecommendation +} diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index aa9abd98..c4fdde27 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -27,6 +27,9 @@ jest.mock('inquirer', () => ({ prompt: jest.fn(), createPromptModule: jest.fn() })) +jest.mock('../../../src/lib/template-recommendation', () => ({ + getAIRecommendation: jest.fn() +})) // mock ora jest.mock('ora', () => { @@ -240,6 +243,12 @@ describe('Command Prototype', () => { expect(TheCommand.flags['confirm-new-workspace'].type).toBe('boolean') expect(TheCommand.flags['confirm-new-workspace'].default).toBe(true) + + expect(TheCommand.flags.chat).toBeDefined() + expect(TheCommand.flags.chat.type).toBe('boolean') + expect(TheCommand.flags.chat.char).toBe('c') + expect(TheCommand.flags.chat.default).toBe(false) + expect(TheCommand.flags.chat.exclusive).toEqual(['repo', 'template', 'import']) }) test('args', async () => { @@ -510,6 +519,146 @@ describe('--no-login', () => { command.argv = ['--yes', '--no-login', '--linter=invalid'] await expect(command.run()).rejects.toThrow('Expected --linter=invalid to be one of: none, basic, adobe-recommended\nSee more help with --help') }) + + test('--chat --no-login (AI mode)', async () => { + const installOptions = { + useDefaultValues: false, + installNpm: true, + installConfig: false, + templates: ['@adobe/generator-app-excshell'] + } + command.getTemplatesWithAI = jest.fn().mockResolvedValue(['@adobe/generator-app-excshell']) + command.runCodeGenerators = jest.fn() + + command.argv = ['--chat', '--no-login'] + await command.run() + + expect(command.getTemplatesWithAI).toHaveBeenCalledWith(expect.objectContaining({ + chat: true, + login: false, + install: true, + linter: 'basic' + })) + expect(command.installTemplates).toHaveBeenCalledWith(installOptions) + expect(LibConsoleCLI.init).not.toHaveBeenCalled() + }) + + test('--chat cannot be used with --template', async () => { + command.argv = ['--chat', '--template', '@adobe/my-template', '--no-login'] + await expect(command.run()).rejects.toThrow() + }) + + test('--chat cannot be used with --repo', async () => { + command.argv = ['--chat', '--repo', 'adobe/appbuilder-quickstarts/qr-code', '--no-login'] + await expect(command.run()).rejects.toThrow() + }) + + test('--chat with login (covers line 168)', async () => { + const { getAIRecommendation } = require('../../../src/lib/template-recommendation') + getAIRecommendation.mockResolvedValue({ name: '@adobe/generator-app-excshell' }) + inquirer.prompt.mockResolvedValueOnce({ userPrompt: 'I want a web app' }) + .mockResolvedValueOnce({ confirm: true }) + + command.argv = ['--chat'] // with login (default) + await command.run() + + expect(getAIRecommendation).toHaveBeenCalledWith('I want a web app') + expect(command.installTemplates).toHaveBeenCalledWith(expect.objectContaining({ + useDefaultValues: false, + installNpm: true, + templates: ['@adobe/generator-app-excshell'] + })) + expect(LibConsoleCLI.init).toHaveBeenCalled() + }) +}) + +describe('getTemplatesWithAI', () => { + let getAIRecommendation + + beforeEach(() => { + const templateRecommendation = require('../../../src/lib/template-recommendation') + getAIRecommendation = templateRecommendation.getAIRecommendation + jest.clearAllMocks() + }) + + test('should return template when user accepts AI recommendation', async () => { + getAIRecommendation.mockResolvedValue({ + name: '@adobe/generator-app-excshell', + description: 'Experience Cloud SPA' + }) + inquirer.prompt + .mockResolvedValueOnce({ userPrompt: 'I want a web app' }) + .mockResolvedValueOnce({ confirm: true }) + + const result = await command.getTemplatesWithAI({}) + + expect(result).toEqual(['@adobe/generator-app-excshell']) + expect(getAIRecommendation).toHaveBeenCalledWith('I want a web app') + }) + + test('should fallback to getTemplatesForFlags when user declines recommendation', async () => { + getAIRecommendation.mockResolvedValue({ + name: '@adobe/test-template', + description: 'Test template' + }) + inquirer.prompt + .mockResolvedValueOnce({ userPrompt: 'test prompt' }) + .mockResolvedValueOnce({ confirm: false }) + command.getTemplatesForFlags = jest.fn().mockResolvedValue(['@adobe/fallback-template']) + + const result = await command.getTemplatesWithAI({}, null) + + expect(result).toEqual(['@adobe/fallback-template']) + expect(command.getTemplatesForFlags).toHaveBeenCalledWith({}, null) + }) + + test('should fallback to getTemplatesForFlags when AI returns null', async () => { + getAIRecommendation.mockResolvedValue(null) + inquirer.prompt.mockResolvedValueOnce({ userPrompt: 'nonsense prompt' }) + command.getTemplatesForFlags = jest.fn().mockResolvedValue(['@adobe/fallback']) + + const result = await command.getTemplatesWithAI({}, null) + + expect(result).toEqual(['@adobe/fallback']) + expect(command.getTemplatesForFlags).toHaveBeenCalled() + }) + + test('should fallback to getTemplatesForFlags when AI returns template without name', async () => { + getAIRecommendation.mockResolvedValue({ description: 'no name field' }) + inquirer.prompt.mockResolvedValueOnce({ userPrompt: 'test' }) + command.getTemplatesForFlags = jest.fn().mockResolvedValue(['@adobe/fallback']) + + const result = await command.getTemplatesWithAI({}) + + expect(result).toEqual(['@adobe/fallback']) + expect(command.getTemplatesForFlags).toHaveBeenCalled() + }) + + test('should fallback to getTemplatesForFlags on API error', async () => { + getAIRecommendation.mockRejectedValue(new Error('API Error')) + inquirer.prompt.mockResolvedValueOnce({ userPrompt: 'test' }) + command.getTemplatesForFlags = jest.fn().mockResolvedValue(['@adobe/fallback']) + + const result = await command.getTemplatesWithAI({}) + + expect(result).toEqual(['@adobe/fallback']) + expect(command.getTemplatesForFlags).toHaveBeenCalled() + }) + + test('should validate empty prompt and reject empty input', async () => { + let capturedValidator + inquirer.prompt.mockImplementationOnce(async (questions) => { + capturedValidator = questions[0].validate + return { userPrompt: 'valid input' } + }).mockResolvedValueOnce({ confirm: true }) + getAIRecommendation.mockResolvedValue({ name: '@adobe/test' }) + await command.getTemplatesWithAI({}) + + // Test the validator that was captured + expect(capturedValidator('')).toBe('Please provide a description of what you want to build.') + expect(capturedValidator(' ')).toBe('Please provide a description of what you want to build.') + expect(capturedValidator('valid input')).toBe(true) + }) }) describe('--login', () => { diff --git a/test/lib/template-recommendation.test.js b/test/lib/template-recommendation.test.js new file mode 100644 index 00000000..24ba76bf --- /dev/null +++ b/test/lib/template-recommendation.test.js @@ -0,0 +1,135 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// Unmock the module (in case it's mocked by other tests like init.test.js) +jest.unmock('../../src/lib/template-recommendation') + +// Mock fetch before requiring the module +global.fetch = jest.fn() + +const { getAIRecommendation } = require('../../src/lib/template-recommendation') + +describe('template-recommendation', () => { + beforeEach(() => { + jest.clearAllMocks() + delete process.env.TEMPLATE_RECOMMENDATION_API + }) + + describe('getAIRecommendation', () => { + test('should return template from API response', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ template: '@adobe/generator-app-excshell', description: 'Test' }) + }) + + const result = await getAIRecommendation('I want a web app') + + expect(result).toEqual({ template: '@adobe/generator-app-excshell', description: 'Test' }) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('recommend-template'), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ prompt: 'I want a web app' }) + }) + ) + }) + + test('should return data.body when response has body wrapper', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + body: { template: '@adobe/test-template' } + }) + }) + + const result = await getAIRecommendation('test prompt') + + expect(result).toEqual({ template: '@adobe/test-template' }) + }) + + test('should use environment variable URL when set', async () => { + process.env.TEMPLATE_RECOMMENDATION_API = 'https://custom-env.url/api' + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ template: 'test' }) + }) + + await getAIRecommendation('test') + + expect(global.fetch).toHaveBeenCalledWith( + 'https://custom-env.url/api', + expect.any(Object) + ) + }) + + test('should use provided apiUrl parameter over env var', async () => { + process.env.TEMPLATE_RECOMMENDATION_API = 'https://env.url/api' + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ template: 'test' }) + }) + + await getAIRecommendation('test', 'https://param.url/api') + + expect(global.fetch).toHaveBeenCalledWith( + 'https://param.url/api', + expect.any(Object) + ) + }) + + test('should throw error when API returns non-ok response', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error' + }) + + await expect(getAIRecommendation('test')).rejects.toThrow('API returned status 500') + }) + + test('should throw error on network failure', async () => { + global.fetch.mockRejectedValue(new Error('Network error')) + + await expect(getAIRecommendation('test')).rejects.toThrow('Network error') + }) + + test('should handle empty response body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({}) + }) + + const result = await getAIRecommendation('test') + + expect(result).toEqual({}) + }) + + test('should pass prompt in request body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ template: 'test' }) + }) + + await getAIRecommendation('my custom prompt') + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ prompt: 'my custom prompt' }) + }) + ) + }) + }) +})