From 4dfb4eabdb09a18af60e898c7517374016050d7a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 28 Jul 2026 19:58:34 -0400 Subject: [PATCH 1/3] Add Claude Code plugin hint on first CLI boot Assisted-By: devx/f7eeb23f-1e44-45b6-8486-65a383fc1918 --- .../cli-kit/src/public/node/hooks/prerun.ts | 4 ++ .../src/public/node/plugin-hints.test.ts | 47 +++++++++++++++++++ .../cli-kit/src/public/node/plugin-hints.ts | 35 ++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 packages/cli-kit/src/public/node/plugin-hints.test.ts create mode 100644 packages/cli-kit/src/public/node/plugin-hints.ts diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index a974dfda724..19b1fb359e9 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -15,6 +15,10 @@ export const hook: Hook.Prerun = async (options) => { }) const args = options.argv + // Emit on every invocation; Claude Code owns deduplication and covers first-party and plugin commands. + const {emitClaudeCodePluginHint} = await import('../plugin-hints.js') + emitClaudeCodePluginHint() + // Load heavy modules in parallel const [{outputDebug}, analyticsMod, notificationsMod] = await Promise.all([ import('../output.js'), diff --git a/packages/cli-kit/src/public/node/plugin-hints.test.ts b/packages/cli-kit/src/public/node/plugin-hints.test.ts new file mode 100644 index 00000000000..4a1cfbd90a4 --- /dev/null +++ b/packages/cli-kit/src/public/node/plugin-hints.test.ts @@ -0,0 +1,47 @@ +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('returns false without a truthy Claude Code variable', () => { + expect(runningUnderClaudeCode({CLAUDECODE: '0', CLAUDE_CODE_CHILD_SESSION: 'false'})).toBe(false) + }) +}) + +describe('emitClaudeCodePluginHint', () => { + let write: ReturnType + + beforeEach(() => { + write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + test('writes the exact marker on every 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`) + }) + + test('does not write outside Claude Code', () => { + emitClaudeCodePluginHint({}) + + expect(write).not.toHaveBeenCalled() + }) + + test('does not make errors fatal', () => { + write.mockImplementation(() => { + throw new Error('write failed') + }) + + expect(() => emitClaudeCodePluginHint({CLAUDECODE: '1'})).not.toThrow() + }) +}) diff --git a/packages/cli-kit/src/public/node/plugin-hints.ts b/packages/cli-kit/src/public/node/plugin-hints.ts new file mode 100644 index 00000000000..b003a695d3e --- /dev/null +++ b/packages/cli-kit/src/public/node/plugin-hints.ts @@ -0,0 +1,35 @@ +import {getEnvironmentVariables} from './environment.js' +import {isTruthy} from './context/utilities.js' +import {outputDebug} from './output.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 = getEnvironmentVariables()): boolean { + return isTruthy(environment.CLAUDECODE) || isTruthy(environment.CLAUDE_CODE_CHILD_SESSION) +} + +/** + * Emits the Claude Code plugin hint on every invocation under Claude Code. + * Claude Code handles deduplication and persistence. A failed optional integration + * must not make the user's command fail. + * + * @param environment - Environment variables to inspect. + */ +export function emitClaudeCodePluginHint(environment = getEnvironmentVariables()): void { + if (!runningUnderClaudeCode(environment)) return + + try { + process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Unable to emit Claude Code plugin hint: ${(error as Error).message}`) + } +} From 8c5ebef6de689b6b40bc76f38b1b8c0a3d17363f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 28 Jul 2026 20:27:18 -0400 Subject: [PATCH 2/3] Emit Claude Code plugin hint on every command invocation Assisted-By: devx/e5b7002e-1154-4597-9d58-f24fc29b6ad7 --- .../src/private/node/plugin-hints.test.ts | 80 +++++++++++++++++++ .../{public => private}/node/plugin-hints.ts | 15 ++-- .../node/hooks/prerun.integration.test.ts | 44 ++++++++++ .../cli-kit/src/public/node/hooks/prerun.ts | 12 ++- .../src/public/node/plugin-hints.test.ts | 47 ----------- 5 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 packages/cli-kit/src/private/node/plugin-hints.test.ts rename packages/cli-kit/src/{public => private}/node/plugin-hints.ts (67%) create mode 100644 packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts delete mode 100644 packages/cli-kit/src/public/node/plugin-hints.test.ts 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..593a0298951 --- /dev/null +++ b/packages/cli-kit/src/private/node/plugin-hints.test.ts @@ -0,0 +1,80 @@ +import {emitClaudeCodePluginHint, runningUnderClaudeCode, SHOPIFY_AI_TOOLKIT_PLUGIN_HINT} from './plugin-hints.js' +import {afterEach, 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) + }) + + afterEach(() => { + write.mockRestore() + stdout.mockRestore() + }) + + 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`) + }) + + test('does not make errors fatal, including diagnostic errors', () => { + write.mockImplementation(() => { + throw new Error('write failed') + }) + + expect(() => emitClaudeCodePluginHint({CLAUDECODE: '1'})).not.toThrow() + }) +}) diff --git a/packages/cli-kit/src/public/node/plugin-hints.ts b/packages/cli-kit/src/private/node/plugin-hints.ts similarity index 67% rename from packages/cli-kit/src/public/node/plugin-hints.ts rename to packages/cli-kit/src/private/node/plugin-hints.ts index b003a695d3e..41d5580ddb0 100644 --- a/packages/cli-kit/src/public/node/plugin-hints.ts +++ b/packages/cli-kit/src/private/node/plugin-hints.ts @@ -1,6 +1,6 @@ -import {getEnvironmentVariables} from './environment.js' -import {isTruthy} from './context/utilities.js' -import {outputDebug} from './output.js' +import {getEnvironmentVariables} from '../../public/node/environment.js' +import {isTruthy} from '../../public/node/context/utilities.js' +import {outputDebug} from '../../public/node/output.js' /** The Claude Code plugin hint protocol marker. */ export const SHOPIFY_AI_TOOLKIT_PLUGIN_HINT = @@ -17,7 +17,7 @@ export function runningUnderClaudeCode(environment = getEnvironmentVariables()): } /** - * Emits the Claude Code plugin hint on every invocation under Claude Code. + * Emits the Claude Code plugin hint on every command invocation under Claude Code. * Claude Code handles deduplication and persistence. A failed optional integration * must not make the user's command fail. * @@ -30,6 +30,11 @@ export function emitClaudeCodePluginHint(environment = getEnvironmentVariables() process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { - outputDebug(`Unable to emit Claude Code plugin hint: ${(error as Error).message}`) + try { + outputDebug(`Unable to emit Claude Code plugin hint: ${(error as Error).message}`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Hint emission and its diagnostics are both best effort. + } } } 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..eaf9332c815 --- /dev/null +++ b/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts @@ -0,0 +1,44 @@ +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()})) +vi.mock('../../../private/node/plugin-hints.js', () => ({ + SHOPIFY_AI_TOOLKIT_PLUGIN_HINT: + '', + emitClaudeCodePluginHint: () => { + if (process.env.CLAUDECODE) process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + }, +})) + +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) + const {hook} = await import('./prerun.js') + + await expect((hook as any)(options)).resolves.toBeUndefined() + expect(stderr).toHaveBeenCalledWith(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) + }) + + test('completes when the optional emitter module fails to load', async () => { + vi.resetModules() + vi.doMock('../../../private/node/plugin-hints.js', () => Promise.reject(new Error('load failed'))) + const {hook} = await import('./prerun.js') + + await expect((hook as any)(options)).resolves.toBeUndefined() + }) +}) diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index 19b1fb359e9..30f4bab26e4 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -15,9 +15,15 @@ export const hook: Hook.Prerun = async (options) => { }) const args = options.argv - // Emit on every invocation; Claude Code owns deduplication and covers first-party and plugin commands. - const {emitClaudeCodePluginHint} = await import('../plugin-hints.js') - emitClaudeCodePluginHint() + // 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. + try { + const {emitClaudeCodePluginHint} = await import('../../../private/node/plugin-hints.js') + emitClaudeCodePluginHint() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // This optional integration must never make a command fail. + } // Load heavy modules in parallel const [{outputDebug}, analyticsMod, notificationsMod] = await Promise.all([ diff --git a/packages/cli-kit/src/public/node/plugin-hints.test.ts b/packages/cli-kit/src/public/node/plugin-hints.test.ts deleted file mode 100644 index 4a1cfbd90a4..00000000000 --- a/packages/cli-kit/src/public/node/plugin-hints.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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('returns false without a truthy Claude Code variable', () => { - expect(runningUnderClaudeCode({CLAUDECODE: '0', CLAUDE_CODE_CHILD_SESSION: 'false'})).toBe(false) - }) -}) - -describe('emitClaudeCodePluginHint', () => { - let write: ReturnType - - beforeEach(() => { - write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - }) - - test('writes the exact marker on every 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`) - }) - - test('does not write outside Claude Code', () => { - emitClaudeCodePluginHint({}) - - expect(write).not.toHaveBeenCalled() - }) - - test('does not make errors fatal', () => { - write.mockImplementation(() => { - throw new Error('write failed') - }) - - expect(() => emitClaudeCodePluginHint({CLAUDECODE: '1'})).not.toThrow() - }) -}) From cc82adb9ca9b4c9ff6cfe0aa3fcc0cbc18a32d3f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 29 Jul 2026 08:09:30 -0400 Subject: [PATCH 3/3] Remove plugin hint startup cost and dead error handling Address review feedback on the Claude Code plugin hint: - Drop the `getEnvironmentVariables` and `outputDebug` imports from `plugin-hints.ts`. `outputDebug` pulled in `output.js`, a large tree loaded during prerun. The only remaining import is `isTruthy`, which has no imports of its own. - Import `plugin-hints.js` statically in the prerun hook. The dynamic import ran unconditionally and serially ahead of the "load heavy modules in parallel" `Promise.all`, so every command paid for it. With no dependencies left, a static import costs nothing. - Remove the try/catch around `process.stderr.write`. Verified on Node 22+ that no failure mode throws synchronously: a closed fd, EPIPE, `destroy()`, and the file-backed `SyncWriteStream` path all surface as async 'error' events that a try/catch cannot intercept. The `outputDebug` fallback was also circular, since it writes to the same stderr that just failed. - Drop the test asserting resilience to a synchronous write failure. It only passed because a vitest mock threw synchronously, so it validated the mock rather than the code. - Drop the manual mock restores flagged by `no-vi-manual-mock-clear`; `mockReset: true` in `configurations/vite.config.ts` already restores originals between tests. Assisted-By: devx/5bdc955b-6898-4395-a179-b7e01508b2d9 --- .../src/private/node/plugin-hints.test.ts | 15 +---------- .../cli-kit/src/private/node/plugin-hints.ts | 26 +++++++------------ .../node/hooks/prerun.integration.test.ts | 18 +++++-------- .../cli-kit/src/public/node/hooks/prerun.ts | 9 ++----- 4 files changed, 18 insertions(+), 50 deletions(-) diff --git a/packages/cli-kit/src/private/node/plugin-hints.test.ts b/packages/cli-kit/src/private/node/plugin-hints.test.ts index 593a0298951..5e6dad14163 100644 --- a/packages/cli-kit/src/private/node/plugin-hints.test.ts +++ b/packages/cli-kit/src/private/node/plugin-hints.test.ts @@ -1,5 +1,5 @@ import {emitClaudeCodePluginHint, runningUnderClaudeCode, SHOPIFY_AI_TOOLKIT_PLUGIN_HINT} from './plugin-hints.js' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' describe('runningUnderClaudeCode', () => { test.each(['1', 'true', 'TRUE', 'yes', 'YES'])('returns true for CLAUDECODE=%s', (value) => { @@ -31,11 +31,6 @@ describe('emitClaudeCodePluginHint', () => { stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) }) - afterEach(() => { - write.mockRestore() - stdout.mockRestore() - }) - test.each([ ['CLAUDECODE', '1'], ['CLAUDECODE', 'true'], @@ -69,12 +64,4 @@ describe('emitClaudeCodePluginHint', () => { expect(write).toHaveBeenNthCalledWith(1, `${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) expect(write).toHaveBeenNthCalledWith(2, `${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) }) - - test('does not make errors fatal, including diagnostic errors', () => { - write.mockImplementation(() => { - throw new Error('write failed') - }) - - expect(() => emitClaudeCodePluginHint({CLAUDECODE: '1'})).not.toThrow() - }) }) diff --git a/packages/cli-kit/src/private/node/plugin-hints.ts b/packages/cli-kit/src/private/node/plugin-hints.ts index 41d5580ddb0..cd6e7e20d5e 100644 --- a/packages/cli-kit/src/private/node/plugin-hints.ts +++ b/packages/cli-kit/src/private/node/plugin-hints.ts @@ -1,6 +1,7 @@ -import {getEnvironmentVariables} from '../../public/node/environment.js' +// 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' -import {outputDebug} from '../../public/node/output.js' /** The Claude Code plugin hint protocol marker. */ export const SHOPIFY_AI_TOOLKIT_PLUGIN_HINT = @@ -12,29 +13,20 @@ export const SHOPIFY_AI_TOOLKIT_PLUGIN_HINT = * @param environment - Environment variables to inspect. * @returns Whether Claude Code environment markers are truthy. */ -export function runningUnderClaudeCode(environment = getEnvironmentVariables()): boolean { +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. A failed optional integration - * must not make the user's command fail. + * Claude Code handles deduplication and persistence. * * @param environment - Environment variables to inspect. */ -export function emitClaudeCodePluginHint(environment = getEnvironmentVariables()): void { +export function emitClaudeCodePluginHint(environment: NodeJS.ProcessEnv = process.env): void { if (!runningUnderClaudeCode(environment)) return - try { - process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - try { - outputDebug(`Unable to emit Claude Code plugin hint: ${(error as Error).message}`) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // Hint emission and its diagnostics are both best effort. - } - } + // 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 index eaf9332c815..b8bddf77c5f 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.integration.test.ts @@ -1,3 +1,4 @@ +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' @@ -7,13 +8,6 @@ vi.mock('../notifications-system.js', () => ({fetchNotificationsInBackground: vi 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()})) -vi.mock('../../../private/node/plugin-hints.js', () => ({ - SHOPIFY_AI_TOOLKIT_PLUGIN_HINT: - '', - emitClaudeCodePluginHint: () => { - if (process.env.CLAUDECODE) process.stderr.write(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) - }, -})) const options = { Command: {id: 'app:dev', aliases: [], plugin: {alias: '@shopify/cli'}}, @@ -28,17 +22,17 @@ describe('prerun hook plugin hint integration', () => { 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) - const {hook} = await import('./prerun.js') await expect((hook as any)(options)).resolves.toBeUndefined() expect(stderr).toHaveBeenCalledWith(`${SHOPIFY_AI_TOOLKIT_PLUGIN_HINT}\n`) }) - test('completes when the optional emitter module fails to load', async () => { - vi.resetModules() - vi.doMock('../../../private/node/plugin-hints.js', () => Promise.reject(new Error('load failed'))) - const {hook} = await import('./prerun.js') + 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 30f4bab26e4..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 { @@ -17,13 +18,7 @@ export const hook: Hook.Prerun = async (options) => { // 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. - try { - const {emitClaudeCodePluginHint} = await import('../../../private/node/plugin-hints.js') - emitClaudeCodePluginHint() - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // This optional integration must never make a command fail. - } + emitClaudeCodePluginHint() // Load heavy modules in parallel const [{outputDebug}, analyticsMod, notificationsMod] = await Promise.all([