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
54 changes: 51 additions & 3 deletions messages/agent.generate.authoring-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
137 changes: 86 additions & 51 deletions src/commands/agent/generate/authoring-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -62,45 +61,6 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
}),
};

private static readonly FLAGGABLE_PROMPTS = {
name: {
message: messages.getMessage('flags.name.summary'),
promptMessage: messages.getMessage('flags.name.prompt'),
validate: (d: string): boolean | string =>
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<string, FlaggablePrompt>;

public async run(): Promise<AgentGenerateAuthoringBundleResult> {
const { flags } = await this.parse(AgentGenerateAuthoringBundle);
const { 'output-dir': outputDir } = flags;
Expand All @@ -109,7 +69,7 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
throw new SfError(messages.getMessage('error.specAndNoSpec'));
}

// Resolve spec: --no-spec => undefined (default spec), --spec <path> => path, missing => prompt
// Resolve spec: --no-spec => undefined, --spec <path> => path, missing => wizard prompts
let spec: string | undefined;
if (flags['no-spec']) {
spec = undefined;
Expand All @@ -120,19 +80,89 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
}
spec = specPath;
} else {
spec = await promptForSpecYaml(AgentGenerateAuthoringBundle.FLAGGABLE_PROMPTS['spec']);
// Find spec files in specs/ directory
const specsDir = join(this.project!.getPath(), 'specs');
let specFiles: string[] = [];

if (existsSync(specsDir)) {
specFiles = readdirSync(specsDir).filter(
(f) => (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,
});
Expand All @@ -150,8 +180,10 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
const agentPath = join(targetOutputDir, `${bundleApiName}.agent`);
const metaXmlPath = join(targetOutputDir, `${bundleApiName}.bundle-meta.xml`);

// Write Agent file
this.spinner.start(messages.getMessage('progress.title', [bundleApiName]));

const parsedSpec = spec ? (YAML.parse(readFileSync(spec, 'utf8')) as AgentJobSpec) : undefined;

await ScriptAgent.createAuthoringBundle({
agentSpec: {
...parsedSpec,
Expand All @@ -163,16 +195,19 @@ export default class AgentGenerateAuthoringBundle extends SfCommand<AgentGenerat
bundleApiName,
});

this.logSuccess(`Successfully generated ${bundleApiName} Authoring Bundle`);
this.spinner.stop();

this.logSuccess(messages.getMessage('success.message', [name]));

return {
agentPath,
metaXmlPath,
outputDir: targetOutputDir,
};
} catch (error) {
this.spinner.stop('failed');
const err = SfError.wrap(error);
throw new SfError(messages.getMessage('error.failed-to-create-agent'), 'AgentGenerationError', [err.message]);
throw new SfError(messages.getMessage('error.failed-to-create-agent', [err.message]), 'AgentGenerationError');
}
}
}
Loading
Loading