diff --git a/messages/agent.generate.authoring-bundle.md b/messages/agent.generate.authoring-bundle.md index c0da1de0..55e89f6d 100644 --- a/messages/agent.generate.authoring-bundle.md +++ b/messages/agent.generate.authoring-bundle.md @@ -16,7 +16,7 @@ This command requires an org because it uses it to access an LLM for generating # flags.spec.summary -Path to the agent spec YAML file. If you don't specify the flag, the command provides a list that you can choose from. Use the --no-spec flag to skip using an agent spec entirely. +Path to the agent spec YAML file. If you don't specify the flag, the command provides a list that you can choose from. Use the --no-spec flag to skip using an agent spec entirely. # flags.spec.prompt @@ -44,7 +44,7 @@ API name of the new authoring bundle; if not specified, the API name is derived # flags.api-name.prompt -API name of the new authoring bundle +Enter authoring bundle API name # examples @@ -78,4 +78,52 @@ The specified file is not a valid agent spec YAML file. # error.failed-to-create-agent -Failed to create an authoring bundle from the agent spec YAML file. +Failed to generate authoring bundle: %s. + +# wizard.specType.prompt + +Select an authoring bundle template + +# wizard.specType.option.default.name + +Default template (Recommended) + +# wizard.specType.option.default.description + +Start with a ready-to-use Agent Script template. + +# wizard.specType.option.fromSpec.name + +From an agent spec YAML file (Advanced) + +# wizard.specType.option.fromSpec.description + +Generate an Agent Script file from an existing agent spec YAML file. + +# wizard.specFile.prompt + +Select the agent spec YAML file + +# wizard.name.prompt + +Enter the authoring bundle name + +# wizard.name.validation.required + +Authoring bundle name is required. + +# wizard.name.validation.empty + +Authoring bundle name can't be empty. + +# progress.title + +Generating authoring bundle: %s + +# success.message + +Authoring bundle "%s" was generated successfully. + +# warning.noSpecDir + +No agent spec directory found at %s. diff --git a/src/commands/agent/generate/authoring-bundle.ts b/src/commands/agent/generate/authoring-bundle.ts index 88d0eb46..8d2ef1f4 100644 --- a/src/commands/agent/generate/authoring-bundle.ts +++ b/src/commands/agent/generate/authoring-bundle.ts @@ -15,14 +15,13 @@ */ import { join, resolve } from 'node:path'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { generateApiName, Messages, SfError } from '@salesforce/core'; import { AgentJobSpec, ScriptAgent } from '@salesforce/agents'; import YAML from 'yaml'; -import { input as inquirerInput } from '@inquirer/prompts'; +import { select, input as inquirerInput } from '@inquirer/prompts'; import { theme } from '../../../inquirer-theme.js'; -import { FlaggablePrompt, promptForFlag, promptForSpecYaml } from '../../../flags.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.generate.authoring-bundle'); @@ -62,45 +61,6 @@ export default class AgentGenerateAuthoringBundle extends SfCommand - d.trim().length > 0 || 'Name cannot be empty or contain only whitespace', - required: true, - }, - 'api-name': { - message: messages.getMessage('flags.api-name.summary'), - promptMessage: messages.getMessage('flags.api-name.prompt'), - validate: (d: string): boolean | string => { - if (d.length === 0) { - return true; - } - if (d.length > 80) { - return 'API name cannot be over 80 characters.'; - } - const regex = /^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]+$/; - if (!regex.test(d)) { - return 'Invalid API name.'; - } - return true; - }, - }, - spec: { - message: messages.getMessage('flags.spec.summary'), - promptMessage: messages.getMessage('flags.spec.prompt'), - validate: (d: string): boolean | string => { - const specPath = resolve(d); - if (!existsSync(specPath)) { - return 'Please enter an existing agent spec (yaml) file'; - } - return true; - }, - required: true, - }, - } satisfies Record; - public async run(): Promise { const { flags } = await this.parse(AgentGenerateAuthoringBundle); const { 'output-dir': outputDir } = flags; @@ -109,7 +69,7 @@ export default class AgentGenerateAuthoringBundle extends SfCommand undefined (default spec), --spec => path, missing => prompt + // Resolve spec: --no-spec => undefined, --spec => path, missing => wizard prompts let spec: string | undefined; if (flags['no-spec']) { spec = undefined; @@ -120,19 +80,89 @@ export default class AgentGenerateAuthoringBundle extends SfCommand (f.endsWith('.yaml') || f.endsWith('.yml')) && !f.includes('-testSpec') + ); + } else { + this.warn(messages.getMessage('warning.noSpecDir', [specsDir])); + } + + // Build spec type choices + const specTypeChoices: Array<{ name: string; value: 'default' | 'fromSpec'; description: string }> = [ + { + name: messages.getMessage('wizard.specType.option.default.name'), + value: 'default', + description: messages.getMessage('wizard.specType.option.default.description'), + }, + ]; + + if (specFiles.length > 0) { + specTypeChoices.push({ + name: messages.getMessage('wizard.specType.option.fromSpec.name'), + value: 'fromSpec', + description: messages.getMessage('wizard.specType.option.fromSpec.description'), + }); + } + + const specType = await select({ + message: messages.getMessage('wizard.specType.prompt'), + choices: specTypeChoices, + theme, + }); + + if (specType === 'fromSpec') { + const selectedFile = await select({ + message: messages.getMessage('wizard.specFile.prompt'), + choices: specFiles.map((f) => ({ name: f, value: join(specsDir, f) })), + theme, + }); + spec = selectedFile; + } else { + spec = undefined; + } } - // If we don't have a name yet, prompt for it - const name = flags['name'] ?? (await promptForFlag(AgentGenerateAuthoringBundle.FLAGGABLE_PROMPTS['name'])); + // Resolve name: --name flag or prompt + const name = + flags['name'] ?? + (await inquirerInput({ + message: messages.getMessage('wizard.name.prompt'), + validate: (d: string): boolean | string => { + if (d.length === 0) { + return messages.getMessage('wizard.name.validation.required'); + } + if (d.trim().length === 0) { + return messages.getMessage('wizard.name.validation.empty'); + } + return true; + }, + theme, + })); - // If we don't have an api name yet, prompt for it + // Resolve API name: --api-name flag or auto-generate from name with prompt to confirm let bundleApiName = flags['api-name']; if (!bundleApiName) { bundleApiName = generateApiName(name); const promptedValue = await inquirerInput({ message: messages.getMessage('flags.api-name.prompt'), - validate: AgentGenerateAuthoringBundle.FLAGGABLE_PROMPTS['api-name'].validate, + validate: (d: string): boolean | string => { + if (d.length === 0) { + return true; + } + if (d.length > 80) { + return 'API name cannot be over 80 characters.'; + } + const regex = /^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]+$/; + if (!regex.test(d)) { + return 'Invalid API name.'; + } + return true; + }, default: bundleApiName, theme, }); @@ -150,8 +180,10 @@ export default class AgentGenerateAuthoringBundle extends SfCommand; + validate?: (input: string) => boolean | string; + default?: string; +}; + +describe('agent generate authoring-bundle', () => { + const $$ = new TestContext(); + let selectStub: sinon.SinonStub; + let inputStub: sinon.SinonStub; + let createAuthoringBundleStub: sinon.SinonStub; + let AgentGenerateAuthoringBundle: any; + + beforeEach(async () => { + selectStub = $$.SANDBOX.stub(); + inputStub = $$.SANDBOX.stub(); + createAuthoringBundleStub = $$.SANDBOX.stub().resolves(); + + // Use esmock to replace ESM module imports + const mod = await esmock('../../../../src/commands/agent/generate/authoring-bundle.js', { + '@inquirer/prompts': { + select: selectStub, + input: inputStub, + }, + '@salesforce/agents': { + ScriptAgent: { + createAuthoringBundle: createAuthoringBundleStub, + }, + }, + }); + + AgentGenerateAuthoringBundle = mod.default; + + // Tell TestContext we're in a project context + $$.inProject(true); + + const mockProject = { + getPath: () => MOCK_PROJECT_DIR, + getDefaultPackage: () => ({ + fullPath: join(MOCK_PROJECT_DIR, 'force-app'), + }), + } as unknown as SfProject; + + // Stub both resolve (used by framework) and getInstance (used by command code) + $$.SANDBOX.stub(SfProject, 'resolve').resolves(mockProject); + $$.SANDBOX.stub(SfProject, 'getInstance').returns(mockProject); + }); + + afterEach(() => { + $$.restore(); + }); + + describe('flag-based (non-interactive) usage', () => { + it('should generate with --no-spec and --name and --api-name', async () => { + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'My Agent', + '--api-name', + 'MyAgent', + '--target-org', + 'test@org.com', + ]); + + expect(result.agentPath).to.include('MyAgent.agent'); + expect(result.metaXmlPath).to.include('MyAgent.bundle-meta.xml'); + expect(result.outputDir).to.include(join('aiAuthoringBundles', 'MyAgent')); + expect(createAuthoringBundleStub.calledOnce).to.be.true; + + const callArgs = createAuthoringBundleStub.firstCall.args[0] as CreateAuthoringBundleArgs; + expect(callArgs.bundleApiName).to.equal('MyAgent'); + expect(callArgs.agentSpec.name).to.equal('My Agent'); + expect(callArgs.agentSpec.developerName).to.equal('MyAgent'); + expect(callArgs.agentSpec.role).to.equal('My Agent description'); + }); + + it('should generate with --spec pointing to a file', async () => { + const specPath = join(MOCK_PROJECT_DIR, 'specs', 'agentSpec.yaml'); + + const result = await AgentGenerateAuthoringBundle.run([ + '--spec', + specPath, + '--name', + 'Spec Agent', + '--api-name', + 'SpecAgent', + '--target-org', + 'test@org.com', + ]); + + expect(result.agentPath).to.include('SpecAgent.agent'); + expect(createAuthoringBundleStub.calledOnce).to.be.true; + + const callArgs = createAuthoringBundleStub.firstCall.args[0] as CreateAuthoringBundleArgs; + expect(callArgs.agentSpec.role).to.equal('test agent role'); + expect(callArgs.agentSpec.companyName).to.equal('Test Company Name'); + }); + + it('should throw when --spec and --no-spec are both provided', async () => { + try { + await AgentGenerateAuthoringBundle.run([ + '--spec', + 'some/path.yaml', + '--no-spec', + '--name', + 'Agent', + '--api-name', + 'Agent', + '--target-org', + 'test@org.com', + ]); + expect.fail('Expected error'); + } catch (error) { + expect((error as Error).message).to.include("can't specify both"); + } + }); + + it('should throw when --spec points to nonexistent file', async () => { + try { + await AgentGenerateAuthoringBundle.run([ + '--spec', + '/nonexistent/path.yaml', + '--name', + 'Agent', + '--api-name', + 'Agent', + '--target-org', + 'test@org.com', + ]); + expect.fail('Expected error'); + } catch (error) { + expect((error as Error).message).to.include('No agent spec YAML file found'); + } + }); + + it('should auto-generate API name default from --name when --api-name is not provided', async () => { + inputStub.resolves('MyCustomApiName'); + + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'My Custom Agent', + '--target-org', + 'test@org.com', + ]); + + expect(inputStub.calledOnce).to.be.true; + const inputCall = inputStub.firstCall.args[0] as PromptConfig; + expect(inputCall.default).to.equal(generateApiName('My Custom Agent')); + expect(result.outputDir).to.include('MyCustomApiName'); + }); + }); + + describe('wizard (interactive) usage', () => { + it('should prompt for spec type, name, and api name when no flags provided', async () => { + selectStub.resolves('default'); + inputStub.onFirstCall().resolves('Interactive Agent'); + inputStub.onSecondCall().resolves('InteractiveAgent'); + + const result = await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + + expect(selectStub.calledOnce).to.be.true; + expect(inputStub.calledTwice).to.be.true; + expect(result.agentPath).to.include('InteractiveAgent.agent'); + + const selectCall = selectStub.firstCall.args[0] as PromptConfig; + expect(selectCall.message).to.equal('Select an authoring bundle template'); + + const nameInputCall = inputStub.firstCall.args[0] as PromptConfig; + expect(nameInputCall.message).to.equal('Enter the authoring bundle name'); + }); + + it('should show spec file selection when "fromSpec" is chosen', async () => { + selectStub.onFirstCall().resolves('fromSpec'); + selectStub.onSecondCall().resolves(join(MOCK_PROJECT_DIR, 'specs', 'agentSpec.yaml')); + inputStub.onFirstCall().resolves('From Spec Agent'); + inputStub.onSecondCall().resolves('FromSpecAgent'); + + const result = await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + + expect(selectStub.calledTwice).to.be.true; + expect(result.agentPath).to.include('FromSpecAgent.agent'); + + const specFileCall = selectStub.secondCall.args[0] as PromptConfig; + expect(specFileCall.message).to.equal('Select the agent spec YAML file'); + + const callArgs = createAuthoringBundleStub.firstCall.args[0] as CreateAuthoringBundleArgs; + expect(callArgs.agentSpec.role).to.equal('test agent role'); + }); + + it('should only show default template when no spec files exist', async () => { + // Override project path to a dir without specs/ + const noSpecsProject = { + getPath: () => '/tmp/no-specs-here', + getDefaultPackage: () => ({ + fullPath: join(MOCK_PROJECT_DIR, 'force-app'), + }), + } as unknown as SfProject; + (SfProject.resolve as sinon.SinonStub).resolves(noSpecsProject); + (SfProject.getInstance as sinon.SinonStub).returns(noSpecsProject); + + selectStub.resolves('default'); + inputStub.onFirstCall().resolves('No Spec Agent'); + inputStub.onSecondCall().resolves('NoSpecAgent'); + + await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + + const selectCall = selectStub.firstCall.args[0] as PromptConfig; + expect(selectCall.choices).to.have.length(1); + expect(selectCall.choices![0].value).to.equal('default'); + }); + + it('should show both template options when spec files exist', async () => { + selectStub.resolves('default'); + inputStub.onFirstCall().resolves('Agent'); + inputStub.onSecondCall().resolves('Agent'); + + await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + + const selectCall = selectStub.firstCall.args[0] as PromptConfig; + expect(selectCall.choices).to.have.length(2); + expect(selectCall.choices![0].value).to.equal('default'); + expect(selectCall.choices![0].name).to.equal('Default template (Recommended)'); + expect(selectCall.choices![1].value).to.equal('fromSpec'); + expect(selectCall.choices![1].name).to.equal('From an agent spec YAML file (Advanced)'); + }); + }); + + describe('name validation', () => { + let validate: (input: string) => boolean | string; + + beforeEach(async () => { + selectStub.resolves('default'); + inputStub.onFirstCall().resolves('Valid Name'); + inputStub.onSecondCall().resolves('ValidName'); + await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + validate = (inputStub.firstCall.args[0] as PromptConfig).validate!; + }); + + it('should reject empty name', () => { + expect(validate('')).to.equal('Authoring bundle name is required.'); + }); + + it('should reject whitespace-only name', () => { + expect(validate(' ')).to.equal("Authoring bundle name can't be empty."); + }); + + it('should accept valid name', () => { + expect(validate('My Agent')).to.be.true; + }); + }); + + describe('API name validation', () => { + let validate: (input: string) => boolean | string; + + beforeEach(async () => { + selectStub.resolves('default'); + inputStub.onFirstCall().resolves('Agent'); + inputStub.onSecondCall().resolves('Agent'); + await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + validate = (inputStub.secondCall.args[0] as PromptConfig).validate!; + }); + + it('should reject API names over 80 characters', () => { + expect(validate('A'.repeat(81))).to.equal('API name cannot be over 80 characters.'); + }); + + it('should reject invalid API name characters', () => { + expect(validate('invalid-name!')).to.equal('Invalid API name.'); + }); + + it('should accept valid API names', () => { + expect(validate('MyAgent01')).to.be.true; + expect(validate('My_Agent_Name')).to.be.true; + }); + + it('should accept empty API name (uses default)', () => { + expect(validate('')).to.be.true; + }); + }); + + describe('error handling', () => { + it('should wrap errors from ScriptAgent.createAuthoringBundle', async () => { + createAuthoringBundleStub.rejects(new Error('Generation failed')); + + try { + await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Agent', + '--api-name', + 'Agent', + '--target-org', + 'test@org.com', + ]); + expect.fail('Expected error'); + } catch (error) { + expect((error as Error).message).to.include('Failed to generate authoring bundle'); + expect((error as Error).message).to.include('Generation failed'); + expect((error as Error).name).to.equal('AgentGenerationError'); + } + }); + }); + + describe('spec file filtering', () => { + it('should filter out test spec files from the list', async () => { + selectStub.onFirstCall().resolves('fromSpec'); + selectStub.onSecondCall().resolves(join(MOCK_PROJECT_DIR, 'specs', 'agentSpec.yaml')); + inputStub.onFirstCall().resolves('Agent'); + inputStub.onSecondCall().resolves('Agent'); + + await AgentGenerateAuthoringBundle.run(['--target-org', 'test@org.com']); + + const specFileCall = selectStub.secondCall.args[0] as PromptConfig; + const choiceNames = specFileCall.choices!.map((c) => c.name); + for (const name of choiceNames) { + expect(name).to.not.include('-testSpec'); + } + }); + }); +});