diff --git a/packages/cli-kit/src/private/node/plugin-hints.test.ts b/packages/cli-kit/src/private/node/plugin-hints.test.ts new file mode 100644 index 00000000000..5e6dad14163 --- /dev/null +++ b/packages/cli-kit/src/private/node/plugin-hints.test.ts @@ -0,0 +1,67 @@ +import {emitClaudeCodePluginHint, runningUnderClaudeCode, SHOPIFY_AI_TOOLKIT_PLUGIN_HINT} from './plugin-hints.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +describe('runningUnderClaudeCode', () => { + test.each(['1', 'true', 'TRUE', 'yes', 'YES'])('returns true for CLAUDECODE=%s', (value) => { + expect(runningUnderClaudeCode({CLAUDECODE: value})).toBe(true) + }) + + test('returns true for a Claude Code child session', () => { + expect(runningUnderClaudeCode({CLAUDE_CODE_CHILD_SESSION: '1'})).toBe(true) + }) + + test.each([ + ['CLAUDECODE', ''], + ['CLAUDECODE', '0'], + ['CLAUDECODE', 'false'], + ['CLAUDE_CODE_CHILD_SESSION', ''], + ['CLAUDE_CODE_CHILD_SESSION', '0'], + ['CLAUDE_CODE_CHILD_SESSION', 'false'], + ])('returns false for %s=%s when the other marker is absent', (variable, value) => { + expect(runningUnderClaudeCode({[variable]: value})).toBe(false) + }) +}) + +describe('emitClaudeCodePluginHint', () => { + let write: ReturnType + let stdout: ReturnType + + beforeEach(() => { + write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + test.each([ + ['CLAUDECODE', '1'], + ['CLAUDECODE', 'true'], + ['CLAUDE_CODE_CHILD_SESSION', '1'], + ['CLAUDE_CODE_CHILD_SESSION', 'true'], + ])('writes the marker for %s=%s', (variable, value) => { + emitClaudeCodePluginHint({[variable]: value}) + + expect(write).toHaveBeenCalledWith(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + expect(stdout).not.toHaveBeenCalled() + }) + + test.each([ + ['CLAUDECODE', ''], + ['CLAUDECODE', '0'], + ['CLAUDECODE', 'false'], + ['CLAUDE_CODE_CHILD_SESSION', ''], + ['CLAUDE_CODE_CHILD_SESSION', '0'], + ['CLAUDE_CODE_CHILD_SESSION', 'false'], + ])('does not write for %s=%s', (variable, value) => { + emitClaudeCodePluginHint({[variable]: value}) + + expect(write).not.toHaveBeenCalled() + }) + + test('writes the exact marker on every command invocation under Claude Code', () => { + emitClaudeCodePluginHint({CLAUDECODE: '1'}) + emitClaudeCodePluginHint({CLAUDECODE: '1'}) + + expect(write).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenNthCalledWith(1, `${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + expect(write).toHaveBeenNthCalledWith(2, `${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + }) +}) diff --git a/packages/cli-kit/src/private/node/plugin-hints.ts b/packages/cli-kit/src/private/node/plugin-hints.ts new file mode 100644 index 00000000000..cd6e7e20d5e --- /dev/null +++ b/packages/cli-kit/src/private/node/plugin-hints.ts @@ -0,0 +1,32 @@ +// This module is imported statically from the prerun hook, which runs before the CLI's +// heavy modules load. Keep it dependency-free (isTruthy has no imports) so it adds no +// measurable startup cost. +import {isTruthy} from '../../public/node/context/utilities.js' + +/** The Claude Code plugin hint protocol marker. */ +export const SHOPIFY_AI_TOOLKIT_PLUGIN_HINT = + '' + +/** + * Returns whether this process was launched by Claude Code. + * + * @param environment - Environment variables to inspect. + * @returns Whether Claude Code environment markers are truthy. + */ +export function runningUnderClaudeCode(environment: NodeJS.ProcessEnv = process.env): boolean { + return isTruthy(environment.CLAUDECODE) || isTruthy(environment.CLAUDE_CODE_CHILD_SESSION) +} + +/** + * Emits the Claude Code plugin hint on every command invocation under Claude Code. + * Claude Code handles deduplication and persistence. + * + * @param environment - Environment variables to inspect. + */ +export function emitClaudeCodePluginHint(environment: NodeJS.ProcessEnv = process.env): void { + if (!runningUnderClaudeCode(environment)) return + + // stderr.write doesn't throw synchronously on supported Node versions; stream failures + // surface as async 'error' events that a try/catch here couldn't intercept anyway. + process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) +} diff --git a/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts b/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts new file mode 100644 index 00000000000..b8bddf77c5f --- /dev/null +++ b/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts @@ -0,0 +1,38 @@ +import {hook} from './prerun.js' +import {SHOPIFY_AI_TOOLKIT_PLUGIN_HINT} from '../../../private/node/plugin-hints.js' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../output.js', () => ({outputDebug: vi.fn()})) +vi.mock('../../../private/node/analytics.js', () => ({startAnalytics: vi.fn().mockResolvedValue(undefined)})) +vi.mock('../notifications-system.js', () => ({fetchNotificationsInBackground: vi.fn()})) +vi.mock('../../../common/version.js', () => ({CLI_KIT_VERSION: '1.0.0'})) +vi.mock('../version.js', () => ({isPreReleaseVersion: vi.fn().mockReturnValue(true)})) +vi.mock('../node-package-manager.js', () => ({checkForNewVersion: vi.fn()})) + +const options = { + Command: {id: 'app:dev', aliases: [], plugin: {alias: '@shopify/cli'}}, + argv: [], +} as any + +describe('prerun hook plugin hint integration', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('writes the marker to stderr and completes under Claude Code', async () => { + vi.stubEnv('CLAUDECODE', '1') + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await expect((hook as any)(options)).resolves.toBeUndefined() + expect(stderr).toHaveBeenCalledWith(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + }) + + test('does not write the marker outside Claude Code', async () => { + vi.stubEnv('CLAUDECODE', '') + vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '') + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await expect((hook as any)(options)).resolves.toBeUndefined() + expect(stderr).not.toHaveBeenCalledWith(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + }) +}) diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index a974dfda724..e70533c7a9d 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -1,3 +1,4 @@ +import {emitClaudeCodePluginHint} from '../../../private/node/plugin-hints.js' import {Hook} from '@oclif/core' export declare interface CommandContent { @@ -15,6 +16,10 @@ export const hook: Hook.Prerun = async (options) => { }) const args = options.argv + // Emit on every resolved command invocation; --help, --version, and parse errors exit before prerun. + // Claude Code owns deduplication and covers first-party and plugin commands. + emitClaudeCodePluginHint() + // Load heavy modules in parallel const [{outputDebug}, analyticsMod, notificationsMod] = await Promise.all([ import('../output.js'),