From 5da8abd3482ab94c247ce22c5416ccea0742ad6b Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 4 May 2026 14:06:18 -0400 Subject: [PATCH] Proto: Add agent conversation flow --- .changeset/warm-ligers-bake.md | 5 + .../cli-kit/src/private/node/analytics.ts | 15 +- .../cli-kit/src/public/node/agent.test.ts | 90 +++++++++ packages/cli-kit/src/public/node/agent.ts | 174 ++++++++++++++++++ .../cli-kit/src/public/node/analytics.test.ts | 45 +++++ .../commands/agent/conversation/end.test.ts | 28 +++ .../cli/commands/agent/conversation/end.ts | 42 +++++ .../agent/conversation/inspect.test.ts | 29 +++ .../commands/agent/conversation/inspect.ts | 49 +++++ .../commands/agent/conversation/start.test.ts | 40 ++++ .../cli/commands/agent/conversation/start.ts | 74 ++++++++ 11 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 .changeset/warm-ligers-bake.md create mode 100644 packages/cli-kit/src/public/node/agent.test.ts create mode 100644 packages/cli-kit/src/public/node/agent.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/end.test.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/end.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/inspect.test.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/inspect.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/start.test.ts create mode 100644 packages/cli/src/cli/commands/agent/conversation/start.ts diff --git a/.changeset/warm-ligers-bake.md b/.changeset/warm-ligers-bake.md new file mode 100644 index 00000000000..d19464829f4 --- /dev/null +++ b/.changeset/warm-ligers-bake.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': patch +--- + +Prototype `shopify agent conversation start|inspect|end` commands that let Shopify CLI mint and persist conversation-scoped agent context for later analytics attribution. diff --git a/packages/cli-kit/src/private/node/analytics.ts b/packages/cli-kit/src/private/node/analytics.ts index 499fc8ef24d..13ad93c420d 100644 --- a/packages/cli-kit/src/private/node/analytics.ts +++ b/packages/cli-kit/src/private/node/analytics.ts @@ -10,6 +10,7 @@ import {ciPlatform, cloudEnvironment, macAddress} from '../../public/node/contex import {cwd} from '../../public/node/path.js' import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from '../../public/node/is-global.js' import {isWsl} from '../../public/node/system.js' +import {resolveShopifyAgentEnvironmentVariables} from '../../public/node/agent.js' import {Command, Interfaces} from '@oclif/core' @@ -103,18 +104,16 @@ export async function getEnvironmentData(config: Interfaces.Config): Promise key.startsWith('SHOPIFY_'))) + // variables. Shopify CLI also supports a conversation-scoped context file via + // SHOPIFY_CLI_AGENT_CONTEXT so callers can mint broader conversation metadata + // once and then reuse the handle across later commands. + return resolveShopifyAgentEnvironmentVariables(process.env) } function getPluginNames(config: Interfaces.Config) { diff --git a/packages/cli-kit/src/public/node/agent.test.ts b/packages/cli-kit/src/public/node/agent.test.ts new file mode 100644 index 00000000000..2e0d966b95e --- /dev/null +++ b/packages/cli-kit/src/public/node/agent.test.ts @@ -0,0 +1,90 @@ +import { + SHOPIFY_CLI_AGENT, + SHOPIFY_CLI_AGENT_CONTEXT, + SHOPIFY_CLI_AGENT_PROVIDER, + SHOPIFY_CLI_AGENT_RUN_ID, + SHOPIFY_CLI_AGENT_SESSION_ID, + SHOPIFY_CLI_AGENT_VERSION, + createAgentConversationContext, + endAgentConversation, + generateConversationId, + inspectAgentConversation, + resolveShopifyAgentEnvironmentVariables, + startAgentConversation, +} from './agent.js' +import * as crypto from './crypto.js' +import {fileExists} from './fs.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./crypto.js') + +describe('agent conversation helpers', () => { + test('generates prefixed conversation IDs', () => { + vi.mocked(crypto.randomUUID).mockReturnValue('uuid-123') + + expect(generateConversationId()).toBe('conv_uuid-123') + }) + + test('creates conversation contexts with generated defaults', () => { + vi.mocked(crypto.randomUUID).mockReturnValue('uuid-123') + + expect(createAgentConversationContext({provider: 'anthropic'})).toMatchObject({ + conversationId: 'conv_uuid-123', + provider: 'anthropic', + }) + }) + + test('starts, inspects, and ends an agent conversation', async () => { + vi.mocked(crypto.randomUUID).mockReturnValue('uuid-123') + + const started = await startAgentConversation({ + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + }) + + expect(started).toMatchObject({ + conversationId: 'conv_uuid-123', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + }) + expect(await fileExists(started.contextPath)).toBe(true) + + const inspected = await inspectAgentConversation({contextPath: started.contextPath}) + expect(inspected).toEqual(started) + + await endAgentConversation({contextPath: started.contextPath}) + expect(await fileExists(started.contextPath)).toBe(false) + }) + + test('resolves explicit and conversation-backed SHOPIFY environment variables', async () => { + const started = await startAgentConversation({ + conversationId: 'conv_existing', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + }) + + const resolved = await resolveShopifyAgentEnvironmentVariables({ + [SHOPIFY_CLI_AGENT_CONTEXT]: started.contextPath, + [SHOPIFY_CLI_AGENT_RUN_ID]: 'run-123', + [SHOPIFY_CLI_AGENT]: 'override-agent', + }) + + expect(resolved).toMatchObject({ + [SHOPIFY_CLI_AGENT_CONTEXT]: started.contextPath, + [SHOPIFY_CLI_AGENT_SESSION_ID]: 'conv_existing', + [SHOPIFY_CLI_AGENT_PROVIDER]: 'shopify', + [SHOPIFY_CLI_AGENT_VERSION]: '0.70.2', + [SHOPIFY_CLI_AGENT_RUN_ID]: 'run-123', + [SHOPIFY_CLI_AGENT]: 'override-agent', + }) + + await endAgentConversation({contextPath: started.contextPath}) + }) +}) diff --git a/packages/cli-kit/src/public/node/agent.ts b/packages/cli-kit/src/public/node/agent.ts new file mode 100644 index 00000000000..399d620d412 --- /dev/null +++ b/packages/cli-kit/src/public/node/agent.ts @@ -0,0 +1,174 @@ +import {randomUUID} from './crypto.js' +import {AbortError} from './error.js' +import {fileExists, mkTmpDir, readFile, removeFile, writeFile} from './fs.js' +import {outputContent, outputDebug, outputToken} from './output.js' +import {joinPath} from './path.js' + +export const SHOPIFY_CLI_AGENT_CONTEXT = 'SHOPIFY_CLI_AGENT_CONTEXT' +export const SHOPIFY_CLI_AGENT = 'SHOPIFY_CLI_AGENT' +export const SHOPIFY_CLI_AGENT_VERSION = 'SHOPIFY_CLI_AGENT_VERSION' +export const SHOPIFY_CLI_AGENT_PROVIDER = 'SHOPIFY_CLI_AGENT_PROVIDER' +export const SHOPIFY_CLI_AGENT_MODEL = 'SHOPIFY_CLI_AGENT_MODEL' +export const SHOPIFY_CLI_AGENT_HARNESS = 'SHOPIFY_CLI_AGENT_HARNESS' +export const SHOPIFY_CLI_AGENT_RUN_ID = 'SHOPIFY_CLI_AGENT_RUN_ID' +export const SHOPIFY_CLI_AGENT_SESSION_ID = 'SHOPIFY_CLI_AGENT_SESSION_ID' + +const AGENT_CONVERSATION_FILENAME = 'shopify-agent-conversation.json' +const START_AGENT_CONVERSATION_COMMAND = 'shopify agent conversation start --json' + +export interface AgentConversationContext { + conversationId: string + agent?: string + agentVersion?: string + provider?: string + harness?: string + model?: string + startedAt: string +} + +export interface AgentConversationHandle extends AgentConversationContext { + contextPath: string +} + +export interface StartAgentConversationInput { + conversationId?: string + agent?: string + agentVersion?: string + provider?: string + harness?: string + model?: string + startedAt?: string +} + +export function generateConversationId(): string { + return `conv_${randomUUID()}` +} + +export function createAgentConversationContext(input: StartAgentConversationInput = {}): AgentConversationContext { + return { + conversationId: input.conversationId ?? generateConversationId(), + agent: input.agent, + agentVersion: input.agentVersion, + provider: input.provider, + harness: input.harness, + model: input.model, + startedAt: input.startedAt ?? new Date().toISOString(), + } +} + +export async function startAgentConversation(input: StartAgentConversationInput = {}): Promise { + const context = createAgentConversationContext(input) + const contextDirectory = await mkTmpDir() + const contextPath = joinPath(contextDirectory, AGENT_CONVERSATION_FILENAME) + await writeFile(contextPath, JSON.stringify(context, null, 2)) + return {...context, contextPath} +} + +export async function inspectAgentConversation(options: { + contextPath?: string + env?: NodeJS.ProcessEnv +} = {}): Promise { + const contextPath = options.contextPath ?? options.env?.[SHOPIFY_CLI_AGENT_CONTEXT] + if (!contextPath) throw noActiveAgentConversationError() + + if (!(await fileExists(contextPath))) { + throw new AbortError( + `Shopify agent conversation context was not found at ${contextPath}.`, + `Start a new one with ${START_AGENT_CONVERSATION_COMMAND}.`, + ) + } + + const parsed = parseAgentConversationContext(await readFile(contextPath)) + return {...parsed, contextPath} +} + +export async function endAgentConversation(options: { + contextPath?: string + env?: NodeJS.ProcessEnv +} = {}): Promise { + const conversation = await inspectAgentConversation(options) + await removeFile(conversation.contextPath) + return conversation +} + +export function agentConversationEnvironmentVariables( + conversation: AgentConversationContext, + contextPath: string, +): Record { + return { + [SHOPIFY_CLI_AGENT_CONTEXT]: contextPath, + [SHOPIFY_CLI_AGENT_SESSION_ID]: conversation.conversationId, + ...(conversation.agent ? {[SHOPIFY_CLI_AGENT]: conversation.agent} : {}), + ...(conversation.agentVersion ? {[SHOPIFY_CLI_AGENT_VERSION]: conversation.agentVersion} : {}), + ...(conversation.provider ? {[SHOPIFY_CLI_AGENT_PROVIDER]: conversation.provider} : {}), + ...(conversation.model ? {[SHOPIFY_CLI_AGENT_MODEL]: conversation.model} : {}), + ...(conversation.harness ? {[SHOPIFY_CLI_AGENT_HARNESS]: conversation.harness} : {}), + } +} + +export async function resolveShopifyAgentEnvironmentVariables( + env: NodeJS.ProcessEnv = process.env, +): Promise> { + const explicitShopifyVariables = Object.fromEntries( + Object.entries(env).filter(([key, value]) => key.startsWith('SHOPIFY_') && typeof value === 'string'), + ) as Record + + const contextPath = explicitShopifyVariables[SHOPIFY_CLI_AGENT_CONTEXT] + if (!contextPath) return explicitShopifyVariables + + try { + const conversation = await inspectAgentConversation({contextPath}) + return {...agentConversationEnvironmentVariables(conversation, contextPath), ...explicitShopifyVariables} + } catch (error) { + outputDebug( + outputContent`Failed to load Shopify agent conversation context from ${outputToken.path(contextPath)}: ${outputToken.raw( + error instanceof Error ? error.message : String(error), + )}`, + ) + return explicitShopifyVariables + } +} + +function noActiveAgentConversationError(): AbortError { + return new AbortError( + 'No active Shopify agent conversation was found.', + `Start one with ${START_AGENT_CONVERSATION_COMMAND}.`, + ) +} + +function parseAgentConversationContext(content: string): AgentConversationContext { + const parsed = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + throw new Error('Shopify agent conversation context must be a JSON object.') + } + + const conversationId = requiredString(parsed, 'conversationId') + const startedAt = requiredString(parsed, 'startedAt') + + return { + conversationId, + startedAt, + agent: optionalString(parsed, 'agent'), + agentVersion: optionalString(parsed, 'agentVersion'), + provider: optionalString(parsed, 'provider'), + harness: optionalString(parsed, 'harness'), + model: optionalString(parsed, 'model'), + } +} + +function requiredString(object: object, key: string): string { + const value = Reflect.get(object, key) + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Shopify agent conversation context is missing ${key}.`) + } + return value +} + +function optionalString(object: object, key: string): string | undefined { + const value = Reflect.get(object, key) + if (value === undefined) return undefined + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Shopify agent conversation context has an invalid ${key}.`) + } + return value +} diff --git a/packages/cli-kit/src/public/node/analytics.test.ts b/packages/cli-kit/src/public/node/analytics.test.ts index 8d1a68fe80c..4b4ccaa6d8e 100644 --- a/packages/cli-kit/src/public/node/analytics.test.ts +++ b/packages/cli-kit/src/public/node/analytics.test.ts @@ -1,4 +1,5 @@ import {reportAnalyticsEvent, recordTiming, recordError, recordRetry, recordEvent} from './analytics.js' +import {startAgentConversation, endAgentConversation} from './agent.js' import * as os from './os.js' import { analyticsDisabled, @@ -216,6 +217,50 @@ describe('event tracking', () => { expect(shopifyVars).toHaveProperty('SHOPIFY_ANOTHER_VAR', 'another_value') expect(shopifyVars).not.toHaveProperty('NOT_SHOPIFY_VAR') }) + + process.env = originalEnv + }) + + test('expands SHOPIFY_CLI_AGENT_CONTEXT into sensitive analytics fields', async () => { + const originalEnv = {...process.env} + const conversation = await startAgentConversation({ + conversationId: 'conv_existing', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + }) + + process.env.SHOPIFY_CLI_AGENT_CONTEXT = conversation.contextPath + process.env.SHOPIFY_CLI_AGENT_RUN_ID = 'run-123' + + await inProjectWithFile('package.json', async (args) => { + const commandContent = {command: 'dev', topic: 'app'} + await startAnalytics({commandContent, args, currentTime: currentDate.getTime() - 100}) + + const config = { + runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), + plugins: [], + } as any + await reportAnalyticsEvent({config, exitMode: 'ok'}) + + const sensitivePayload = publishEventMock.mock.calls[0]![2] + const shopifyVars = JSON.parse(sensitivePayload.env_shopify_variables as string) + expect(shopifyVars).toMatchObject({ + SHOPIFY_CLI_AGENT_CONTEXT: conversation.contextPath, + SHOPIFY_CLI_AGENT_SESSION_ID: 'conv_existing', + SHOPIFY_CLI_AGENT: 'pi', + SHOPIFY_CLI_AGENT_VERSION: '0.70.2', + SHOPIFY_CLI_AGENT_PROVIDER: 'shopify', + SHOPIFY_CLI_AGENT_HARNESS: 'pi', + SHOPIFY_CLI_AGENT_MODEL: 'gpt-5', + SHOPIFY_CLI_AGENT_RUN_ID: 'run-123', + }) + }) + + await endAgentConversation({contextPath: conversation.contextPath}) + process.env = originalEnv }) test('does nothing when analytics are disabled', async () => { diff --git a/packages/cli/src/cli/commands/agent/conversation/end.test.ts b/packages/cli/src/cli/commands/agent/conversation/end.test.ts new file mode 100644 index 00000000000..2bd31418e6d --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/end.test.ts @@ -0,0 +1,28 @@ +import AgentConversationEnd from './end.js' +import {describe, expect, test, vi} from 'vitest' +import {endAgentConversation} from '@shopify/cli-kit/node/agent' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/agent') + +describe('agent conversation end command', () => { + test('ends a conversation and prints json when requested', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(endAgentConversation).mockResolvedValue({ + conversationId: 'conv_123', + contextPath: '/tmp/agent.json', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + startedAt: '2026-05-01T00:00:00.000Z', + }) + + await AgentConversationEnd.run(['--json']) + + expect(endAgentConversation).toHaveBeenCalledWith({contextPath: undefined}) + expect(outputMock.output()).toContain('"ended": true') + expect(outputMock.output()).toContain('"conversationId": "conv_123"') + }) +}) diff --git a/packages/cli/src/cli/commands/agent/conversation/end.ts b/packages/cli/src/cli/commands/agent/conversation/end.ts new file mode 100644 index 00000000000..98242a12ff4 --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/end.ts @@ -0,0 +1,42 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' +import {endAgentConversation} from '@shopify/cli-kit/node/agent' + +export default class AgentConversationEnd extends Command { + static summary = 'End a Shopify agent conversation context.' + + static descriptionWithMarkdown = `Removes a Shopify agent conversation context file resolved from \ +\`SHOPIFY_CLI_AGENT_CONTEXT\` or an explicit path.` + + static description = this.descriptionWithoutMarkdown() + + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'] + + static flags = { + ...globalFlags, + ...jsonFlag, + context: Flags.string({ + description: 'Path to a Shopify agent conversation context file.', + env: 'SHOPIFY_CLI_AGENT_CONTEXT', + }), + } + + async run(): Promise { + const {flags} = await this.parse(AgentConversationEnd) + const conversation = await endAgentConversation({contextPath: flags.context}) + const result = {...conversation, ended: true} + + if (flags.json) { + return outputResult(JSON.stringify(result, null, 2)) + } + + return outputResult( + [ + `Ended Shopify agent conversation ${conversation.conversationId}.`, + `Removed context path: ${conversation.contextPath}`, + ].join('\n'), + ) + } +} diff --git a/packages/cli/src/cli/commands/agent/conversation/inspect.test.ts b/packages/cli/src/cli/commands/agent/conversation/inspect.test.ts new file mode 100644 index 00000000000..a35a08ebc92 --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/inspect.test.ts @@ -0,0 +1,29 @@ +import AgentConversationInspect from './inspect.js' +import {describe, expect, test, vi} from 'vitest' +import {inspectAgentConversation} from '@shopify/cli-kit/node/agent' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/agent') + +describe('agent conversation inspect command', () => { + test('prints conversation details', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(inspectAgentConversation).mockResolvedValue({ + conversationId: 'conv_123', + contextPath: '/tmp/agent.json', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + startedAt: '2026-05-01T00:00:00.000Z', + }) + + await AgentConversationInspect.run([]) + + expect(inspectAgentConversation).toHaveBeenCalledWith({contextPath: undefined}) + expect(outputMock.output()).toContain('Shopify agent conversation conv_123') + expect(outputMock.output()).toContain('Context path: /tmp/agent.json') + expect(outputMock.output()).toContain('Provider: shopify') + }) +}) diff --git a/packages/cli/src/cli/commands/agent/conversation/inspect.ts b/packages/cli/src/cli/commands/agent/conversation/inspect.ts new file mode 100644 index 00000000000..c84bd68d361 --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/inspect.ts @@ -0,0 +1,49 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' +import {inspectAgentConversation} from '@shopify/cli-kit/node/agent' + +export default class AgentConversationInspect extends Command { + static summary = 'Inspect the active Shopify agent conversation context.' + + static descriptionWithMarkdown = `Reads the current Shopify agent conversation context from \ +\`SHOPIFY_CLI_AGENT_CONTEXT\` or an explicit path.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --context /tmp/shopify-agent-conversation.json --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + context: Flags.string({ + description: 'Path to a Shopify agent conversation context file.', + env: 'SHOPIFY_CLI_AGENT_CONTEXT', + }), + } + + async run(): Promise { + const {flags} = await this.parse(AgentConversationInspect) + const conversation = await inspectAgentConversation({contextPath: flags.context}) + + if (flags.json) { + return outputResult(JSON.stringify(conversation, null, 2)) + } + + return outputResult( + [ + `Shopify agent conversation ${conversation.conversationId}`, + `Context path: ${conversation.contextPath}`, + ...(conversation.agent ? [`Agent: ${conversation.agent}`] : []), + ...(conversation.provider ? [`Provider: ${conversation.provider}`] : []), + ...(conversation.harness ? [`Harness: ${conversation.harness}`] : []), + ...(conversation.model ? [`Model: ${conversation.model}`] : []), + ...(conversation.agentVersion ? [`Agent version: ${conversation.agentVersion}`] : []), + ].join('\n'), + ) + } +} diff --git a/packages/cli/src/cli/commands/agent/conversation/start.test.ts b/packages/cli/src/cli/commands/agent/conversation/start.test.ts new file mode 100644 index 00000000000..4999f9676ec --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/start.test.ts @@ -0,0 +1,40 @@ +import AgentConversationStart from './start.js' +import {describe, expect, test, vi} from 'vitest' +import {startAgentConversation} from '@shopify/cli-kit/node/agent' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/agent') + +describe('agent conversation start command', () => { + test('starts a conversation and prints json when requested', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(startAgentConversation).mockResolvedValue({ + conversationId: 'conv_123', + contextPath: '/tmp/agent.json', + agent: 'pi', + agentVersion: '0.70.2', + provider: 'shopify', + harness: 'pi', + model: 'gpt-5', + startedAt: '2026-05-01T00:00:00.000Z', + }) + + await AgentConversationStart.run(['--json', '--agent', 'pi', '--provider', 'shopify']) + + expect(startAgentConversation).toHaveBeenCalledWith({ + conversationId: undefined, + agent: 'pi', + agentVersion: undefined, + provider: 'shopify', + harness: undefined, + model: undefined, + }) + expect(outputMock.output()).toContain('"conversationId": "conv_123"') + expect(outputMock.output()).toContain('"contextPath": "/tmp/agent.json"') + }) + + test('documents the conversation-id flag', () => { + expect(AgentConversationStart.flags['conversation-id']).toBeDefined() + expect(AgentConversationStart.flags['conversation-id'].env).toBe('CONVERSATION_ID') + }) +}) diff --git a/packages/cli/src/cli/commands/agent/conversation/start.ts b/packages/cli/src/cli/commands/agent/conversation/start.ts new file mode 100644 index 00000000000..14d517b79f0 --- /dev/null +++ b/packages/cli/src/cli/commands/agent/conversation/start.ts @@ -0,0 +1,74 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' +import {startAgentConversation} from '@shopify/cli-kit/node/agent' + +export default class AgentConversationStart extends Command { + static summary = 'Start a Shopify agent conversation context for later CLI commands.' + + static descriptionWithMarkdown = `Starts a conversation-scoped Shopify agent context and writes it to a temporary context file. + +Pass \ +\`--conversation-id\` when your host already has a broader conversation identifier, or omit it and let Shopify CLI generate one.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --agent pi --provider shopify --model gpt-5 --json', + '<%= config.bin %> <%= command.id %> --conversation-id conv_123 --agent claude-code --provider anthropic', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + 'conversation-id': Flags.string({ + description: 'Conversation identifier to reuse. If omitted, Shopify CLI generates one.', + env: 'CONVERSATION_ID', + }), + agent: Flags.string({ + description: 'Agent name to associate with the conversation.', + env: 'SHOPIFY_CLI_AGENT', + }), + 'agent-version': Flags.string({ + description: 'Agent version to associate with the conversation.', + env: 'SHOPIFY_CLI_AGENT_VERSION', + }), + provider: Flags.string({ + description: 'Agent provider to associate with the conversation.', + env: 'SHOPIFY_CLI_AGENT_PROVIDER', + }), + harness: Flags.string({ + description: 'Harness or host app running the agent conversation.', + env: 'SHOPIFY_CLI_AGENT_HARNESS', + }), + model: Flags.string({ + description: 'Model identifier to associate with the conversation.', + env: 'SHOPIFY_CLI_AGENT_MODEL', + }), + } + + async run(): Promise { + const {flags} = await this.parse(AgentConversationStart) + const conversation = await startAgentConversation({ + conversationId: flags['conversation-id'], + agent: flags.agent, + agentVersion: flags['agent-version'], + provider: flags.provider, + harness: flags.harness, + model: flags.model, + }) + + if (flags.json) { + return outputResult(JSON.stringify(conversation, null, 2)) + } + + return outputResult( + [ + `Started Shopify agent conversation ${conversation.conversationId}.`, + `Context path: ${conversation.contextPath}`, + 'Reuse the context path as SHOPIFY_CLI_AGENT_CONTEXT on later shopify commands.', + ].join('\n'), + ) + } +}