diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 6c748d18..97527a68 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -28,6 +28,9 @@ Slash commands provide meta-level control over the CLI itself. - **`/compress`** - **Description:** Replace the entire chat context with a summary. This saves on tokens used for future tasks while retaining a high level summary of what has happened. +- **`/copy`** + - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. + - **`/editor`** - **Description:** Open a dialog for selecting supported editors. diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index d03bf988..de4ff2ea 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -11,6 +11,7 @@ import { type SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { chatCommand } from '../ui/commands/chatCommand.js'; @@ -51,6 +52,9 @@ vi.mock('../ui/commands/authCommand.js', () => ({ vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: { name: 'theme', description: 'Mock Theme' }, })); +vi.mock('../ui/commands/copyCommand.js', () => ({ + copyCommand: { name: 'copy', description: 'Mock Copy' }, +})); vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: { name: 'privacy', description: 'Mock Privacy' }, })); @@ -89,7 +93,7 @@ vi.mock('../ui/commands/restoreCommand.js', () => ({ })); describe('CommandService', () => { - const subCommandLen = 18; + const subCommandLen = 19; let mockConfig: Mocked; beforeEach(() => { @@ -132,6 +136,7 @@ describe('CommandService', () => { expect(commandNames).toContain('memory'); expect(commandNames).toContain('help'); expect(commandNames).toContain('clear'); + expect(commandNames).toContain('copy'); expect(commandNames).toContain('compress'); expect(commandNames).toContain('corgi'); expect(commandNames).toContain('docs'); @@ -205,6 +210,7 @@ describe('CommandService', () => { bugCommand, chatCommand, clearCommand, + copyCommand, compressCommand, corgiCommand, docsCommand, diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index def8cfcc..99eccbf2 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -9,6 +9,7 @@ import { SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; @@ -36,6 +37,7 @@ const loadBuiltInCommands = async ( bugCommand, chatCommand, clearCommand, + copyCommand, compressCommand, corgiCommand, docsCommand, diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts new file mode 100644 index 00000000..b163b43f --- /dev/null +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -0,0 +1,296 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { copyCommand } from './copyCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { copyToClipboard } from '../utils/commandUtils.js'; + +vi.mock('../utils/commandUtils.js', () => ({ + copyToClipboard: vi.fn(), +})); + +describe('copyCommand', () => { + let mockContext: CommandContext; + let mockCopyToClipboard: Mock; + let mockGetChat: Mock; + let mockGetHistory: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCopyToClipboard = vi.mocked(copyToClipboard); + mockGetChat = vi.fn(); + mockGetHistory = vi.fn(); + + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getChat: mockGetChat, + }), + }, + }, + }); + + mockGetChat.mockReturnValue({ + getHistory: mockGetHistory, + }); + }); + + it('should return info message when no history is available', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetChat.mockReturnValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should return info message when history is empty', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([]); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should return info message when no AI messages are found in history', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithUserOnly = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithUserOnly); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should copy last AI message to clipboard successfully', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'model', + parts: [{ text: 'Hi there! How can I help you?' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Hi there! How can I help you?', + ); + }); + + it('should handle multiple text parts in AI message', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMultipleParts = [ + { + role: 'model', + parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleParts); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should filter out non-text parts', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMixedParts = [ + { + role: 'model', + parts: [ + { text: 'Text part' }, + { image: 'base64data' }, // Non-text part + { text: ' more text' }, + ], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMixedParts); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should get the last AI message when multiple AI messages exist', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMultipleAiMessages = [ + { + role: 'model', + parts: [{ text: 'First AI response' }], + }, + { + role: 'user', + parts: [{ text: 'User message' }], + }, + { + role: 'model', + parts: [{ text: 'Second AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleAiMessages); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should handle clipboard copy error', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'model', + parts: [{ text: 'AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + const clipboardError = new Error('Clipboard access denied'); + mockCopyToClipboard.mockRejectedValue(clipboardError); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to copy to the clipboard.', + }); + }); + + it('should handle non-Error clipboard errors', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'model', + parts: [{ text: 'AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + mockCopyToClipboard.mockRejectedValue('String error'); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to copy to the clipboard.', + }); + }); + + it('should return info message when no text parts found in AI message', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithEmptyParts = [ + { + role: 'model', + parts: [{ image: 'base64data' }], // No text parts + }, + ]; + + mockGetHistory.mockReturnValue(historyWithEmptyParts); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to copy.', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should handle unavailable config service', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const nullConfigContext = createMockCommandContext({ + services: { config: null }, + }); + + const result = await copyCommand.action(nullConfigContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts new file mode 100644 index 00000000..5714b5ab --- /dev/null +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { copyToClipboard } from '../utils/commandUtils.js'; +import { SlashCommand, SlashCommandActionReturn } from './types.js'; + +export const copyCommand: SlashCommand = { + name: 'copy', + description: 'Copy the last result or code snippet to clipboard', + action: async (context, _args): Promise => { + const chat = await context.services.config?.getGeminiClient()?.getChat(); + const history = chat?.getHistory(); + + // Get the last message from the AI (model role) + const lastAiMessage = history + ? history.filter((item) => item.role === 'model').pop() + : undefined; + + if (!lastAiMessage) { + return { + type: 'message', + messageType: 'info', + content: 'No output in history', + }; + } + // Extract text from the parts + const lastAiOutput = lastAiMessage.parts + ?.filter((part) => part.text) + .map((part) => part.text) + .join(''); + + if (lastAiOutput) { + try { + await copyToClipboard(lastAiOutput); + + return { + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.debug(message); + + return { + type: 'message', + messageType: 'error', + content: 'Failed to copy to the clipboard.', + }; + } + } else { + return { + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to copy.', + }; + } + }, +}; diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts new file mode 100644 index 00000000..158a5f7a --- /dev/null +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { + isAtCommand, + isSlashCommand, + copyToClipboard, +} from './commandUtils.js'; + +// Mock child_process +vi.mock('child_process'); + +// Mock process.platform for platform-specific tests +const mockProcess = vi.hoisted(() => ({ + platform: 'darwin', +})); + +vi.stubGlobal('process', { + ...process, + get platform() { + return mockProcess.platform; + }, +}); + +interface MockChildProcess extends EventEmitter { + stdin: EventEmitter & { + write: Mock; + end: Mock; + }; + stderr: EventEmitter; +} + +describe('commandUtils', () => { + let mockSpawn: Mock; + let mockChild: MockChildProcess; + + beforeEach(async () => { + vi.clearAllMocks(); + // Dynamically import and set up spawn mock + const { spawn } = await import('child_process'); + mockSpawn = spawn as Mock; + + // Create mock child process with stdout/stderr emitters + mockChild = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }) as MockChildProcess; + + mockSpawn.mockReturnValue(mockChild as unknown as ReturnType); + }); + + describe('isAtCommand', () => { + it('should return true when query starts with @', () => { + expect(isAtCommand('@file')).toBe(true); + expect(isAtCommand('@path/to/file')).toBe(true); + expect(isAtCommand('@')).toBe(true); + }); + + it('should return true when query contains @ preceded by whitespace', () => { + expect(isAtCommand('hello @file')).toBe(true); + expect(isAtCommand('some text @path/to/file')).toBe(true); + expect(isAtCommand(' @file')).toBe(true); + }); + + it('should return false when query does not start with @ and has no spaced @', () => { + expect(isAtCommand('file')).toBe(false); + expect(isAtCommand('hello')).toBe(false); + expect(isAtCommand('')).toBe(false); + expect(isAtCommand('email@domain.com')).toBe(false); + expect(isAtCommand('user@host')).toBe(false); + }); + + it('should return false when @ is not preceded by whitespace', () => { + expect(isAtCommand('hello@file')).toBe(false); + expect(isAtCommand('text@path')).toBe(false); + }); + }); + + describe('isSlashCommand', () => { + it('should return true when query starts with /', () => { + expect(isSlashCommand('/help')).toBe(true); + expect(isSlashCommand('/memory show')).toBe(true); + expect(isSlashCommand('/clear')).toBe(true); + expect(isSlashCommand('/')).toBe(true); + }); + + it('should return false when query does not start with /', () => { + expect(isSlashCommand('help')).toBe(false); + expect(isSlashCommand('memory show')).toBe(false); + expect(isSlashCommand('')).toBe(false); + expect(isSlashCommand('path/to/file')).toBe(false); + expect(isSlashCommand(' /help')).toBe(false); + }); + }); + + describe('copyToClipboard', () => { + describe('on macOS (darwin)', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + + it('should successfully copy text to clipboard using pbcopy', async () => { + const testText = 'Hello, world!'; + + // Simulate successful execution + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('pbcopy', []); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + + it('should handle pbcopy command failure', async () => { + const testText = 'Hello, world!'; + + // Simulate command failure + setTimeout(() => { + mockChild.stderr.emit('data', 'Command not found'); + mockChild.emit('close', 1); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow( + "'pbcopy' exited with code 1: Command not found", + ); + }); + + it('should handle spawn error', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('error', new Error('spawn error')); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow('spawn error'); + }); + + it('should handle stdin write error', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.stdin.emit('error', new Error('stdin error')); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow('stdin error'); + }); + }); + + describe('on Windows (win32)', () => { + beforeEach(() => { + mockProcess.platform = 'win32'; + }); + + it('should successfully copy text to clipboard using clip', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('clip', []); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + }); + + describe('on Linux', () => { + beforeEach(() => { + mockProcess.platform = 'linux'; + }); + + it('should successfully copy text to clipboard using xclip', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('xclip', [ + '-selection', + 'clipboard', + ]); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + + it('should fallback to xsel when xclip fails', async () => { + const testText = 'Hello, world!'; + let callCount = 0; + + mockSpawn.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }) as MockChildProcess; + + setTimeout(() => { + if (callCount === 0) { + // First call (xclip) fails + child.stderr.emit('data', 'xclip not found'); + child.emit('close', 1); + callCount++; + } else { + // Second call (xsel) succeeds + child.emit('close', 0); + } + }, 0); + + return child as unknown as ReturnType; + }); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [ + '-selection', + 'clipboard', + ]); + expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [ + '--clipboard', + '--input', + ]); + }); + + it('should throw error when both xclip and xsel fail', async () => { + const testText = 'Hello, world!'; + let callCount = 0; + + mockSpawn.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }); + + setTimeout(() => { + if (callCount === 0) { + // First call (xclip) fails + child.stderr.emit('data', 'xclip command not found'); + child.emit('close', 1); + callCount++; + } else { + // Second call (xsel) fails + child.stderr.emit('data', 'xsel command not found'); + child.emit('close', 1); + } + }, 0); + + return child as unknown as ReturnType; + }); + + await expect(copyToClipboard(testText)).rejects.toThrow( + /All copy commands failed/, + ); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + }); + + describe('on unsupported platform', () => { + beforeEach(() => { + mockProcess.platform = 'unsupported'; + }); + + it('should throw error for unsupported platform', async () => { + await expect(copyToClipboard('test')).rejects.toThrow( + 'Unsupported platform: unsupported', + ); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + + it('should handle command exit without stderr', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 1); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow( + "'pbcopy' exited with code 1", + ); + }); + + it('should handle empty text', async () => { + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(''); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(''); + }); + + it('should handle multiline text', async () => { + const multilineText = 'Line 1\nLine 2\nLine 3'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(multilineText); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(multilineText); + }); + + it('should handle special characters', async () => { + const specialText = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(specialText); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(specialText); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index aadd035e..4280388f 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { spawn } from 'child_process'; + /** * Checks if a query string potentially represents an '@' command. * It triggers if the query starts with '@' or contains '@' preceded by whitespace @@ -24,3 +26,57 @@ export const isAtCommand = (query: string): boolean => * @returns True if the query looks like an '/' command, false otherwise. */ export const isSlashCommand = (query: string): boolean => query.startsWith('/'); + +//Copies a string snippet to the clipboard for different platforms +export const copyToClipboard = async (text: string): Promise => { + const run = (cmd: string, args: string[]) => + new Promise((resolve, reject) => { + const child = spawn(cmd, args); + let stderr = ''; + child.stderr.on('data', (chunk) => (stderr += chunk.toString())); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) return resolve(); + const errorMsg = stderr.trim(); + reject( + new Error( + `'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`, + ), + ); + }); + child.stdin.on('error', reject); + child.stdin.write(text); + child.stdin.end(); + }); + + switch (process.platform) { + case 'win32': + return run('clip', []); + case 'darwin': + return run('pbcopy', []); + case 'linux': + try { + await run('xclip', ['-selection', 'clipboard']); + } catch (primaryError) { + try { + // If xclip fails for any reason, try xsel as a fallback. + await run('xsel', ['--clipboard', '--input']); + } catch (fallbackError) { + const primaryMsg = + primaryError instanceof Error + ? primaryError.message + : String(primaryError); + const fallbackMsg = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + throw new Error( + `All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`, + ); + } + } + return; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +};