Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,11 +541,13 @@ USAGE
$ aio app init [PATH] [-v] [--version] [--install] [-y] [--login] [-e <value>... | -t <value>... | --repo
<value>] [--standalone-app | | ] [--template-options <value>] [-o <value> | -i <value> | ] [-p <value> | | ] [-w
<value> | | ] [--confirm-new-workspace] [--use-jwt] [--github-pat <value> ] [--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=<value>... Extension point(s) to implement
-i, --import=<value> Import an Adobe I/O Developer Console configuration file
-o, --org=<value> Specify the Adobe Developer Console Org to init from (orgId, or orgCode)
Expand Down
91 changes: 85 additions & 6 deletions src/commands/app/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <workspace>' 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
Expand Down Expand Up @@ -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']
})
}

Expand Down
12 changes: 11 additions & 1 deletion src/lib/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
}
55 changes: 55 additions & 0 deletions src/lib/template-recommendation.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have any auth enabled for recommendation API? If not it can lead to DOS attacks very easily.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We initially wanted everyone to access the AI prompt. We will look into how to achieve the auth part.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sandeep-paliwal This would be good to discuss - I was thinking maybe we could pass the CLI token and do IMS auth on the backend. That of course would make it so we only allow the recommend flow for the logged in use cases and not noLogin. But maybe that's okay since noLogin is usually non-interactive?

'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
}
149 changes: 149 additions & 0 deletions test/commands/app/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading