Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/warm-ligers-bake.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 7 additions & 8 deletions packages/cli-kit/src/private/node/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -103,18 +104,16 @@ export async function getEnvironmentData(config: Interfaces.Config): Promise<Env
export async function getSensitiveEnvironmentData(config: Interfaces.Config) {
return {
env_plugin_installed_all: JSON.stringify(getPluginNames(config)),
env_shopify_variables: JSON.stringify(getShopifyEnvironmentVariables()),
env_shopify_variables: JSON.stringify(await getShopifyEnvironmentVariables()),
}
}

function getShopifyEnvironmentVariables() {
async function getShopifyEnvironmentVariables() {
// Agent callers can identify themselves today via SHOPIFY_* environment
// variables. The current contract is intentionally lightweight and is kept in
// the sensitive payload until we prove which dimensions deserve first-class
// Monorail fields, e.g. SHOPIFY_CLI_AGENT, SHOPIFY_CLI_AGENT_VERSION,
// SHOPIFY_CLI_AGENT_RUN_ID, SHOPIFY_CLI_AGENT_SESSION_ID, and
// SHOPIFY_CLI_AGENT_PROVIDER.
return Object.fromEntries(Object.entries(process.env).filter(([key]) => 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) {
Expand Down
90 changes: 90 additions & 0 deletions packages/cli-kit/src/public/node/agent.test.ts
Original file line number Diff line number Diff line change
@@ -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})
})
})
174 changes: 174 additions & 0 deletions packages/cli-kit/src/public/node/agent.ts
Original file line number Diff line number Diff line change
@@ -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<AgentConversationHandle> {
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<AgentConversationHandle> {
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<AgentConversationHandle> {
const conversation = await inspectAgentConversation(options)
await removeFile(conversation.contextPath)
return conversation
}

export function agentConversationEnvironmentVariables(
conversation: AgentConversationContext,
contextPath: string,
): Record<string, string> {
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<Record<string, string>> {
const explicitShopifyVariables = Object.fromEntries(
Object.entries(env).filter(([key, value]) => key.startsWith('SHOPIFY_') && typeof value === 'string'),
) as Record<string, string>

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
}
45 changes: 45 additions & 0 deletions packages/cli-kit/src/public/node/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/cli/commands/agent/conversation/end.test.ts
Original file line number Diff line number Diff line change
@@ -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"')
})
})
Loading
Loading