diff --git a/command-snapshot.json b/command-snapshot.json index 2b7ac1d5..ea7d9577 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -150,7 +150,18 @@ "command": "org:open:agent", "flagAliases": ["urlonly"], "flagChars": ["b", "n", "o", "r"], - "flags": ["api-name", "api-version", "browser", "flags-dir", "json", "private", "target-org", "url-only"], + "flags": [ + "api-name", + "api-version", + "authoring-bundle", + "browser", + "flags-dir", + "json", + "private", + "target-org", + "url-only", + "version" + ], "plugin": "@salesforce/plugin-org" }, { diff --git a/messages/open.agent.md b/messages/open.agent.md index 78121b07..b5a911c5 100644 --- a/messages/open.agent.md +++ b/messages/open.agent.md @@ -6,6 +6,8 @@ Open an agent in your org's Agent Builder UI in a browser. Use the --api-name flag to open an agent using its API name in the Agent Builder UI of your org. To find the agent's API name, go to Setup in your org and navigate to the agent's details page. +Alternatively, use the --authoring-bundle flag to open an agent in Agentforce Builder. Optionally include --version to open a specific version of the agent. You'll specify the api name of the authoring bundle. + To generate the URL but not launch it in your browser, specify --url-only. To open Agent Builder in a specific browser, use the --browser flag. Supported browsers are "chrome", "edge", and "firefox". If you don't specify --browser, the org opens in your default browser. @@ -24,6 +26,14 @@ To open Agent Builder in a specific browser, use the --browser flag. Supported b $ <%= config.bin %> <%= command.id %> --target-org MyTestOrg1 --browser firefox --api-name Coral_Cloud_Agent +- Open an agent in Agentforce Builder using its authoring bundle name: + + $ <%= config.bin %> <%= command.id %> --authoring-bundle MyAgent + +- Open a specific version of an agent in Agentforce Builder: + + $ <%= config.bin %> <%= command.id %> --authoring-bundle MyAgent --version 1 + # flags.api-name.summary API name, also known as developer name, of the agent you want to open in the org's Agent Builder UI. @@ -39,3 +49,21 @@ Browser where the org opens. # flags.url-only.summary Display navigation URL, but don’t launch browser. + +# flags.authoring-bundle.summary + +API name of the agent to open in Agentforce Builder. + +# flags.authoring-bundle.description + +The API name of the agent to open directly in Agentforce Builder. Optionally specify --version to open a specific +version. + +# flags.version.summary + +Version number of the agent to open in Agentforce Builder. + +# flags.version.description + +The version number of the agent to open directly in Agentforce Builder. Can only be used with the --authoring-bundle +flag. diff --git a/messages/open.authoring-bundle.md b/messages/open.authoring-bundle.md index 2c1b13f5..b837842a 100644 --- a/messages/open.authoring-bundle.md +++ b/messages/open.authoring-bundle.md @@ -1,11 +1,13 @@ # summary -Open your org in Agentforce Studio, specifically in the list view showing the list of agents. +Open your org in Agentforce Studio, specifically in the list view showing the list of agents, or open a specific agent in Agentforce Builder. # description The list view shows the agents in your org that are implemented with Agent Script and an authoring bundle. Click on an agent name to open it in Agentforce Builder in a new browser window. +To open a specific agent directly in Agentforce Builder, provide the --api-name flag. Optionally include --version to open a specific version of the agent. + To generate the URL but not launch it in your browser, specify --url-only. # examples @@ -14,6 +16,14 @@ To generate the URL but not launch it in your browser, specify --url-only. $ <%= config.bin %> <%= command.id %> +- Open a specific agent directly in Agentforce Builder: + + $ <%= config.bin %> <%= command.id %> --api-name MyAgent + +- Open a specific version of an agent in Agentforce Builder: + + $ <%= config.bin %> <%= command.id %> --api-name MyAgent --version 1 + - Open the agents list view in an incognito window of your default browser: $ <%= config.bin %> <%= command.id %> --private @@ -22,6 +32,26 @@ To generate the URL but not launch it in your browser, specify --url-only. $ <%= config.bin %> <%= command.id %> --target-org MyTestOrg1 --browser firefox +- Open a specific agent in a different org and display the URL only: + + $ <%= config.bin %> <%= command.id %> --api-name MyAgent --version 2 --target-org MyTestOrg1 --url-only + +# flags.api-name.summary + +API name of the agent to open in Agentforce Builder. + +# flags.api-name.description + +The API name of the agent to open directly in Agentforce Builder. Optionally specify --version to open a specific version. + +# flags.version.summary + +Version number of the agent to open in Agentforce Builder. + +# flags.version.description + +The version number of the agent to open directly in Agentforce Builder. Can only be used with the --api-name flag. + # flags.private.summary Open the org in the default browser using private (incognito) mode. diff --git a/src/commands/org/open/agent.ts b/src/commands/org/open/agent.ts index 0dc797ad..6e66824c 100644 --- a/src/commands/org/open/agent.ts +++ b/src/commands/org/open/agent.ts @@ -34,7 +34,7 @@ export class OrgOpenAgent extends OrgOpenCommandBase { 'api-name': Flags.string({ char: 'n', summary: messages.getMessage('flags.api-name.summary'), - required: true, + exactlyOne: ['api-name', 'authoring-bundle'], }), private: Flags.boolean({ summary: messages.getMessage('flags.private.summary'), @@ -52,6 +52,16 @@ export class OrgOpenAgent extends OrgOpenCommandBase { aliases: ['urlonly'], deprecateAliases: true, }), + 'authoring-bundle': Flags.string({ + summary: messages.getMessage('flags.authoring-bundle.summary'), + description: messages.getMessage('flags.authoring-bundle.description'), + exactlyOne: ['api-name', 'authoring-bundle'], + }), + version: Flags.string({ + summary: messages.getMessage('flags.version.summary'), + description: messages.getMessage('flags.version.description'), + dependsOn: ['authoring-bundle'], + }), }; public async run(): Promise { @@ -59,9 +69,22 @@ export class OrgOpenAgent extends OrgOpenCommandBase { this.org = flags['target-org']; this.connection = this.org.getConnection(flags['api-version']); - const agentBuilderRedirect = await buildRetUrl(this.connection, flags['api-name']); + let path: string; + if (flags['api-name']) { + path = await buildRetUrl(this.connection, flags['api-name']); + } else { + // authoring-bundle is provided + const queryParams = new URLSearchParams({ + // flags.authoring-bundle guaranteed by OCLIF definition + projectName: flags['authoring-bundle']!, + }); + if (flags.version) { + queryParams.set('projectVersionNumber', flags.version); + } + path = `AgentAuthoring/agentAuthoringBuilder.app#/project?${queryParams.toString()}`; + } - return this.openOrgUI(flags, await this.org.getFrontDoorUrl(agentBuilderRedirect)); + return this.openOrgUI(flags, await this.org.getFrontDoorUrl(path)); } } diff --git a/src/commands/org/open/authoring-bundle.ts b/src/commands/org/open/authoring-bundle.ts index 0139dc14..39296dba 100644 --- a/src/commands/org/open/authoring-bundle.ts +++ b/src/commands/org/open/authoring-bundle.ts @@ -26,6 +26,8 @@ export class OrgOpenAuthoringBundle extends OrgOpenCommandBase { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'deprecated'; + public static readonly deprecationOptions = { to: 'org open agent --authoring-bundle' }; public static readonly flags = { ...OrgOpenCommandBase.flags, diff --git a/test/unit/org/open/agent.test.ts b/test/unit/org/open/agent.test.ts new file mode 100644 index 00000000..b85c334b --- /dev/null +++ b/test/unit/org/open/agent.test.ts @@ -0,0 +1,354 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventEmitter } from 'node:events'; +import { assert, expect } from 'chai'; +import { Connection, SfdcUrl } from '@salesforce/core'; +import { stubMethod } from '@salesforce/ts-sinon'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { stubSfCommandUx, stubSpinner, stubUx } from '@salesforce/sf-plugins-core'; +import { OrgOpenAgent } from '../../../../src/commands/org/open/agent.js'; +import { OrgOpenOutput } from '../../../../src/shared/orgTypes.js'; +import utils from '../../../../src/shared/orgOpenUtils.js'; + +describe('org:open:agent', () => { + const $$ = new TestContext(); + const testOrg = new MockTestOrgData(); + + const singleUseToken = (Math.random() + 1).toString(36).substring(2); + const expectedDefaultSingleUseUrl = `${testOrg.instanceUrl}/secur/frontdoor.jsp?otp=${singleUseToken}`; + const getExpectedUrlWithPath = (path: string) => `${expectedDefaultSingleUseUrl}&startURL=${path}`; + + const mockBotId = '0Xx1234567890ABCD'; + const mockBotName = 'TestAgent'; + + let sfCommandUxStubs: ReturnType; + + const testJsonStructure = (response: OrgOpenOutput) => { + expect(response).to.have.property('url'); + expect(response).to.have.property('username').equal(testOrg.username); + expect(response).to.have.property('orgId').equal(testOrg.orgId); + }; + + const spies = new Map(); + + beforeEach(async () => { + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); + stubUx($$.SANDBOX); + stubSpinner($$.SANDBOX); + await $$.stubAuths(testOrg); + spies.set('open', stubMethod($$.SANDBOX, utils, 'openUrl').resolves(new EventEmitter())); + spies.set( + 'requestGet', + stubMethod($$.SANDBOX, Connection.prototype, 'requestGet').callsFake((url: string) => { + const urlObj = new URL(url); + const redirectUri = urlObj.searchParams.get('redirect_uri'); + return Promise.resolve({ + // eslint-disable-next-line camelcase + frontdoor_uri: redirectUri + ? `${expectedDefaultSingleUseUrl}&startURL=${redirectUri}` + : expectedDefaultSingleUseUrl, + }); + }) + ); + spies.set('resolver', stubMethod($$.SANDBOX, SfdcUrl.prototype, 'checkLightningDomain').resolves('1.1.1.1')); + spies.set( + 'singleRecordQuery', + stubMethod($$.SANDBOX, Connection.prototype, 'singleRecordQuery').resolves({ Id: mockBotId }) + ); + }); + + afterEach(() => { + spies.clear(); + }); + + describe('flag validation', () => { + it('requires either api-name or authoring-bundle', async () => { + try { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--url-only']); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include('Exactly one of the following must be provided'); + expect((error as Error).message).to.include('--api-name'); + expect((error as Error).message).to.include('--authoring-bundle'); + } + }); + + it('does not allow both api-name and authoring-bundle', async () => { + try { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + '--authoring-bundle', + 'MyAgent', + ]); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include('--api-name'); + expect((error as Error).message).to.include('--authoring-bundle'); + expect((error as Error).message).to.match(/exactly one|cannot also be provided/i); + } + }); + + it('requires authoring-bundle when version is provided', async () => { + try { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + '--version', + '1', + ]); + assert.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect((error as Error).message).to.include('--version'); + expect((error as Error).message).to.include('--authoring-bundle'); + expect((error as Error).message).to.match(/All of the following must be provided|depends on/i); + } + }); + }); + + describe('url generation with api-name', () => { + it('builds URL with api-name using BotDefinition query', async () => { + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-name', + mockBotName, + ]); + assert(response); + testJsonStructure(response); + + // Verify the BotDefinition query was made + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include(mockBotName); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include('BotDefinition'); + + const expectedPath = `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${mockBotId}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('generates single-use URL when --url-only is not passed', async () => { + const response = await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName]); + assert(response); + testJsonStructure(response); + expect(spies.get('requestGet').callCount).to.equal(1); + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + }); + + it('properly queries BotDefinition with special characters in api-name', async () => { + const specialName = 'Test_Agent_01'; + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--url-only', '--api-name', specialName]); + + expect(spies.get('singleRecordQuery').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').firstCall.args[0]).to.include(specialName); + }); + }); + + describe('url generation with authoring-bundle', () => { + it('builds URL with authoring-bundle only', async () => { + const bundleName = 'MyTestAgent'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + bundleName, + ]); + assert(response); + testJsonStructure(response); + + // Verify no BotDefinition query was made + expect(spies.get('singleRecordQuery').callCount).to.equal(0); + + const expectedPath = `AgentAuthoring/agentAuthoringBuilder.app#/project?projectName=${bundleName}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('builds URL with authoring-bundle and version', async () => { + const bundleName = 'MyTestAgent'; + const version = '13'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + bundleName, + '--version', + version, + ]); + assert(response); + testJsonStructure(response); + + const expectedPath = `AgentAuthoring/agentAuthoringBuilder.app#/project?projectName=${bundleName}&projectVersionNumber=${version}`; + expect(response.url).to.equal(getExpectedUrlWithPath(expectedPath)); + }); + + it('generates single-use URL when --url-only is not passed', async () => { + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + ]); + assert(response); + testJsonStructure(response); + expect(spies.get('requestGet').callCount).to.equal(1); + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('singleRecordQuery').callCount).to.equal(0); + }); + }); + + describe('browser integration', () => { + it('opens in specified browser with api-name', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--api-name', + mockBotName, + '--browser', + 'firefox', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.not.eql({}); + }); + + it('opens in specified browser with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + '--browser', + 'firefox', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.not.eql({}); + }); + + it('opens in private mode with api-name', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName, '--private']); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.have.property('newInstance'); + }); + + it('opens in private mode with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--authoring-bundle', + 'MyAgent', + '--private', + ]); + + expect(spies.get('open').callCount).to.equal(1); + expect(spies.get('open').args[0][1]).to.have.property('newInstance'); + }); + }); + + describe('human output', () => { + it('outputs success message without URL when opening browser with api-name', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(1); + }); + + it('outputs success message without URL when opening browser with authoring-bundle', async () => { + await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--authoring-bundle', 'MyAgent']); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(1); + }); + + it('outputs URL when using --url-only with api-name', async () => { + await OrgOpenAgent.run(['--target-org', testOrg.username, '--url-only', '--api-name', mockBotName]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(0); + }); + + it('outputs URL when using --url-only with authoring-bundle', async () => { + await OrgOpenAgent.run([ + '--target-org', + testOrg.username, + '--url-only', + '--authoring-bundle', + 'MyAgent', + '--version', + '1', + ]); + + expect(sfCommandUxStubs.logSuccess.callCount).to.be.greaterThan(0); + expect(spies.get('open').callCount).to.equal(0); + }); + }); + + describe('api-version flag', () => { + it('respects api-version flag with api-name', async () => { + const apiVersion = '59.0'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-version', + apiVersion, + '--api-name', + mockBotName, + ]); + assert(response); + testJsonStructure(response); + }); + + it('respects api-version flag with authoring-bundle', async () => { + const apiVersion = '59.0'; + const response = await OrgOpenAgent.run([ + '--json', + '--target-org', + testOrg.username, + '--url-only', + '--api-version', + apiVersion, + '--authoring-bundle', + 'MyAgent', + ]); + assert(response); + testJsonStructure(response); + }); + }); +});