Skip to content
Merged
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
13 changes: 12 additions & 1 deletion command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@
"command": "agent:generate:authoring-bundle",
"flagAliases": [],
"flagChars": ["d", "f", "n", "o"],
"flags": ["api-name", "api-version", "flags-dir", "json", "name", "no-spec", "output-dir", "spec", "target-org"],
"flags": [
"api-name",
"api-version",
"flags-dir",
"force-overwrite",
"json",
"name",
"no-spec",
"output-dir",
"spec",
"target-org"
],
"plugin": "@salesforce/plugin-agent"
},
{
Expand Down
12 changes: 12 additions & 0 deletions messages/agent.generate.authoring-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ Generating authoring bundle: %s

Authoring bundle "%s" was generated successfully.

# flags.force-overwrite.summary

Overwrite the existing authoring bundle if one with the same API name already exists locally.

# prompt.overwrite

An authoring bundle with the API name "%s" already exists. Overwrite it?

# info.cancel

Canceled authoring bundle generation.

# warning.noSpecDir

No agent spec directory found at %s.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@inquirer/prompts": "^7.10.1",
"@oclif/core": "^4",
"@oclif/multi-stage-output": "^0.8.29",
"@salesforce/agents": "^0.22.6",
"@salesforce/agents": "^0.23.0",
"@salesforce/core": "^8.25.1",
"@salesforce/kit": "^3.2.4",
"@salesforce/sf-plugins-core": "^12.2.6",
Expand Down
138 changes: 93 additions & 45 deletions src/commands/agent/generate/authoring-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AgentJobSpec, ScriptAgent } from '@salesforce/agents';
import YAML from 'yaml';
import { select, input as inquirerInput } from '@inquirer/prompts';
import { theme } from '../../../inquirer-theme.js';
import yesNoOrCancel from '../../../yes-no-cancel.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.generate.authoring-bundle');
Expand All @@ -32,6 +33,79 @@ export type AgentGenerateAuthoringBundleResult = {
outputDir: string;
};

async function resolveUniqueBundle(
baseOutputDir: string,
forceOverwrite: boolean,
flagName?: string,
flagApiName?: string
): Promise<{ name: string; apiName: string } | undefined> {
const name =
flagName ??
(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,
}));

let apiName = flagApiName ?? '';
if (!apiName) {
apiName = generateApiName(name);
const promptedValue = await inquirerInput({
message: 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;
},
default: apiName,
theme,
});
if (promptedValue?.length) {
apiName = promptedValue;
}
}

if (forceOverwrite) {
return { name, apiName };
}

const bundleDir = join(baseOutputDir, 'aiAuthoringBundles', apiName);
if (!existsSync(bundleDir)) {
return { name, apiName };
}

const confirmation = await yesNoOrCancel({
message: messages.getMessage('prompt.overwrite', [apiName]),
default: false,
});

if (confirmation === 'cancel') {
return undefined;
}
if (confirmation) {
return { name, apiName };
}

// User chose "no" — restart from the beginning without flag values
return resolveUniqueBundle(baseOutputDir, false);
}

export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerateAuthoringBundleResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand Down Expand Up @@ -59,6 +133,9 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
summary: messages.getMessage('flags.name.summary'),
char: 'n',
}),
'force-overwrite': Flags.boolean({
summary: messages.getMessage('flags.force-overwrite.summary'),
}),
};

public async run(): Promise<AgentGenerateAuthoringBundleResult> {
Expand Down Expand Up @@ -127,54 +204,25 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
}
}

// 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,
}));

// 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: (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,
});
if (promptedValue?.length) {
bundleApiName = promptedValue;
}
const defaultOutputDir = join(this.project!.getDefaultPackage().fullPath, 'main', 'default');
const baseOutputDir = outputDir ?? defaultOutputDir;

const resolved = await resolveUniqueBundle(
baseOutputDir,
flags['force-overwrite'] ?? false,
flags['name'],
flags['api-name']
);

if (!resolved) {
this.log(messages.getMessage('info.cancel'));
return { agentPath: '', metaXmlPath: '', outputDir: '' };
}

const { name, apiName: bundleApiName } = resolved;

try {
// Get default output directory if not specified
const defaultOutputDir = join(this.project!.getDefaultPackage().fullPath, 'main', 'default');
const targetOutputDir = join(outputDir ?? defaultOutputDir, 'aiAuthoringBundles', bundleApiName);
const targetOutputDir = join(baseOutputDir, 'aiAuthoringBundles', bundleApiName);

// Generate file paths
const agentPath = join(targetOutputDir, `${bundleApiName}.agent`);
Expand Down
106 changes: 106 additions & 0 deletions test/commands/agent/generate/authoring-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ describe('agent generate authoring-bundle', () => {
let selectStub: sinon.SinonStub;
let inputStub: sinon.SinonStub;
let createAuthoringBundleStub: sinon.SinonStub;
let yesNoOrCancelStub: sinon.SinonStub;
let AgentGenerateAuthoringBundle: any;

beforeEach(async () => {
selectStub = $$.SANDBOX.stub();
inputStub = $$.SANDBOX.stub();
createAuthoringBundleStub = $$.SANDBOX.stub().resolves();
yesNoOrCancelStub = $$.SANDBOX.stub();

// Use esmock to replace ESM module imports
const mod = await esmock('../../../../src/commands/agent/generate/authoring-bundle.js', {
Expand All @@ -62,6 +64,9 @@ describe('agent generate authoring-bundle', () => {
createAuthoringBundle: createAuthoringBundleStub,
},
},
'../../../../src/yes-no-cancel.js': {
default: yesNoOrCancelStub,
},
});

AgentGenerateAuthoringBundle = mod.default;
Expand Down Expand Up @@ -353,4 +358,105 @@ describe('agent generate authoring-bundle', () => {
}
});
});

describe('duplicate bundle detection', () => {
// Willie_Resort_Manager already exists in the mock project's aiAuthoringBundles directory
const EXISTING_BUNDLE_API_NAME = 'Willie_Resort_Manager';

it('should prompt when bundle with same API name already exists', async () => {
yesNoOrCancelStub.resolves(true);

const result = await AgentGenerateAuthoringBundle.run([
'--no-spec',
'--name',
'Willie Resort Manager',
'--api-name',
EXISTING_BUNDLE_API_NAME,
'--target-org',
'test@org.com',
]);

expect(yesNoOrCancelStub.calledOnce).to.be.true;
const promptCall = yesNoOrCancelStub.firstCall.args[0] as { message: string };
expect(promptCall.message).to.include(EXISTING_BUNDLE_API_NAME);
expect(createAuthoringBundleStub.calledOnce).to.be.true;
expect(result.agentPath).to.include(`${EXISTING_BUNDLE_API_NAME}.agent`);
});

it('should cancel when user chooses cancel on duplicate prompt', async () => {
yesNoOrCancelStub.resolves('cancel');

const result = await AgentGenerateAuthoringBundle.run([
'--no-spec',
'--name',
'Willie Resort Manager',
'--api-name',
EXISTING_BUNDLE_API_NAME,
'--target-org',
'test@org.com',
]);

expect(yesNoOrCancelStub.calledOnce).to.be.true;
expect(createAuthoringBundleStub.called).to.be.false;
expect(result.agentPath).to.equal('');
});

it('should re-prompt for name and API name when user chooses no on duplicate prompt', async () => {
yesNoOrCancelStub.resolves(false);
// inputStub is called for both name and API name re-prompts
inputStub.onFirstCall().resolves('New Agent');
inputStub.onSecondCall().resolves('NewAgent');

const result = await AgentGenerateAuthoringBundle.run([
'--no-spec',
'--name',
'Willie Resort Manager',
'--api-name',
EXISTING_BUNDLE_API_NAME,
'--target-org',
'test@org.com',
]);

expect(yesNoOrCancelStub.calledOnce).to.be.true;
expect(inputStub.calledTwice).to.be.true;
expect(createAuthoringBundleStub.calledOnce).to.be.true;
const callArgs = createAuthoringBundleStub.firstCall.args[0] as CreateAuthoringBundleArgs;
expect(callArgs.bundleApiName).to.equal('NewAgent');
expect(callArgs.agentSpec.name).to.equal('New Agent');
expect(result.agentPath).to.include('NewAgent.agent');
});

it('should skip duplicate check with --force-overwrite', async () => {
const result = await AgentGenerateAuthoringBundle.run([
'--no-spec',
'--name',
'Willie Resort Manager',
'--api-name',
EXISTING_BUNDLE_API_NAME,
'--force-overwrite',
'--target-org',
'test@org.com',
]);

expect(yesNoOrCancelStub.called).to.be.false;
expect(createAuthoringBundleStub.calledOnce).to.be.true;
expect(result.agentPath).to.include(`${EXISTING_BUNDLE_API_NAME}.agent`);
});

it('should not prompt when API name does not exist locally', async () => {
const result = await AgentGenerateAuthoringBundle.run([
'--no-spec',
'--name',
'Brand New Agent',
'--api-name',
'BrandNewAgent',
'--target-org',
'test@org.com',
]);

expect(yesNoOrCancelStub.called).to.be.false;
expect(createAuthoringBundleStub.calledOnce).to.be.true;
expect(result.agentPath).to.include('BrandNewAgent.agent');
});
});
});
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1743,10 +1743,10 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==

"@salesforce/agents@^0.22.6":
version "0.22.6"
resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.22.6.tgz#35ea2ec80b3da6489b6ff18c63a9a7c4f2ca6e96"
integrity sha512-FOJFNVPsJeDEZ9TBk1YL0O0iwovDhXCdk+gw0+Ow1XB2h0eAY4FFOWWzexTXnYKflsVVvA+1lgEeFQS4bwgJSw==
"@salesforce/agents@^0.23.0":
version "0.23.0"
resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.23.0.tgz#6e2738ca8cc8d1e29e77f91f93ed98c442184368"
integrity sha512-C2a/ndRk+Vpi8B09e1YbECXbd3N8BRVq1PNW563+I3iNYgls41Rpf2SU2T2Pbl61r6lijI+RXT1YQ4gOrGqi8A==
dependencies:
"@salesforce/core" "^8.25.1"
"@salesforce/kit" "^3.2.4"
Expand Down