Download packages/cli/src/commands/hooks/migrate.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 14.1 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/commands/hooks/migrate.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/commands/hooks/migrate.test.ts
-
curl -L -o migrate.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/commands/hooks/migrate.test.ts
14.1 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| describe, | |
| it, | |
| expect, | |
| vi, | |
| beforeEach, | |
| afterEach, | |
| type Mock, | |
| type MockInstance, | |
| } from 'vitest'; | |
| import * as fs from 'node:fs'; | |
| import { loadSettings, SettingScope } from '../../config/settings.js'; | |
| import { debugLogger } from '@google/gemini-cli-core'; | |
| import { handleMigrateFromClaude } from './migrate.js'; | |
| vi.mock('node:fs'); | |
| vi.mock('../utils.js', () => ({ | |
| exitCli: vi.fn(), | |
| })); | |
| vi.mock('../../config/settings.js', async () => { | |
| const actual = await vi.importActual('../../config/settings.js'); | |
| return { | |
| ...actual, | |
| loadSettings: vi.fn(), | |
| }; | |
| }); | |
| const mockedLoadSettings = loadSettings as Mock; | |
| const mockedFs = vi.mocked(fs); | |
| describe('migrate command', () => { | |
| let mockSetValue: Mock; | |
| let debugLoggerLogSpy: MockInstance; | |
| let debugLoggerErrorSpy: MockInstance; | |
| let originalCwd: () => string; | |
| beforeEach(() => { | |
| vi.resetAllMocks(); | |
| mockSetValue = vi.fn(); | |
| debugLoggerLogSpy = vi | |
| .spyOn(debugLogger, 'log') | |
| .mockImplementation(() => {}); | |
| debugLoggerErrorSpy = vi | |
| .spyOn(debugLogger, 'error') | |
| .mockImplementation(() => {}); | |
| // Mock process.cwd() | |
| originalCwd = process.cwd; | |
| process.cwd = vi.fn(() => '/test/project'); | |
| mockedLoadSettings.mockReturnValue({ | |
| merged: { | |
| hooks: {}, | |
| }, | |
| setValue: mockSetValue, | |
| workspace: { path: '/test/project/.gemini' }, | |
| }); | |
| }); | |
| afterEach(() => { | |
| process.cwd = originalCwd; | |
| vi.restoreAllMocks(); | |
| }); | |
| it('should log error when no Claude settings files exist', async () => { | |
| mockedFs.existsSync.mockReturnValue(false); | |
| await handleMigrateFromClaude(); | |
| expect(debugLoggerErrorSpy).toHaveBeenCalledWith( | |
| 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', | |
| ); | |
| expect(mockSetValue).not.toHaveBeenCalled(); | |
| }); | |
| it('should migrate hooks from settings.json when it exists', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| matcher: 'Edit', | |
| hooks: [ | |
| { | |
| type: 'command', | |
| command: 'echo "Before Edit"', | |
| timeout: 30, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockImplementation((path) => | |
| path.toString().endsWith('settings.json'), | |
| ); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| expect(mockSetValue).toHaveBeenCalledWith( | |
| SettingScope.Workspace, | |
| 'hooks', | |
| expect.objectContaining({ | |
| BeforeTool: expect.arrayContaining([ | |
| expect.objectContaining({ | |
| matcher: 'replace', | |
| hooks: expect.arrayContaining([ | |
| expect.objectContaining({ | |
| command: 'echo "Before Edit"', | |
| type: 'command', | |
| timeout: 30, | |
| }), | |
| ]), | |
| }), | |
| ]), | |
| }), | |
| ); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| expect.stringContaining('Found Claude Code settings'), | |
| ); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| expect.stringContaining('Migrating 1 hook event'), | |
| ); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| '✓ Hooks successfully migrated to .gemini/settings.json', | |
| ); | |
| }); | |
| it('should prefer settings.local.json over settings.json', async () => { | |
| const localSettings = { | |
| hooks: { | |
| SessionStart: [ | |
| { | |
| hooks: [ | |
| { | |
| type: 'command', | |
| command: 'echo "Local session start"', | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(localSettings)); | |
| await handleMigrateFromClaude(); | |
| expect(mockedFs.readFileSync).toHaveBeenCalledWith( | |
| expect.stringContaining('settings.local.json'), | |
| 'utf-8', | |
| ); | |
| expect(mockSetValue).toHaveBeenCalledWith( | |
| SettingScope.Workspace, | |
| 'hooks', | |
| expect.objectContaining({ | |
| SessionStart: expect.any(Array), | |
| }), | |
| ); | |
| }); | |
| it('should migrate all supported event types', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [{ hooks: [{ type: 'command', command: 'echo 1' }] }], | |
| PostToolUse: [{ hooks: [{ type: 'command', command: 'echo 2' }] }], | |
| UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo 3' }] }], | |
| Stop: [{ hooks: [{ type: 'command', command: 'echo 4' }] }], | |
| SubAgentStop: [{ hooks: [{ type: 'command', command: 'echo 5' }] }], | |
| SessionStart: [{ hooks: [{ type: 'command', command: 'echo 6' }] }], | |
| SessionEnd: [{ hooks: [{ type: 'command', command: 'echo 7' }] }], | |
| PreCompact: [{ hooks: [{ type: 'command', command: 'echo 8' }] }], | |
| Notification: [{ hooks: [{ type: 'command', command: 'echo 9' }] }], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks).toHaveProperty('BeforeTool'); | |
| expect(migratedHooks).toHaveProperty('AfterTool'); | |
| expect(migratedHooks).toHaveProperty('BeforeAgent'); | |
| expect(migratedHooks).toHaveProperty('AfterAgent'); | |
| expect(migratedHooks).toHaveProperty('SessionStart'); | |
| expect(migratedHooks).toHaveProperty('SessionEnd'); | |
| expect(migratedHooks).toHaveProperty('PreCompress'); | |
| expect(migratedHooks).toHaveProperty('Notification'); | |
| }); | |
| it('should transform tool names in matchers', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| matcher: 'Edit|Bash|Read|Write|Glob|Grep', | |
| hooks: [{ type: 'command', command: 'echo "test"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].matcher).toBe( | |
| 'replace|run_shell_command|read_file|write_file|glob|grep', | |
| ); | |
| }); | |
| it('should replace $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [ | |
| { | |
| type: 'command', | |
| command: 'cd $CLAUDE_PROJECT_DIR && ls', | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe( | |
| 'cd $GEMINI_PROJECT_DIR && ls', | |
| ); | |
| }); | |
| it('should preserve sequential flag', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| sequential: true, | |
| hooks: [{ type: 'command', command: 'echo "test"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].sequential).toBe(true); | |
| }); | |
| it('should preserve timeout values', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [ | |
| { | |
| type: 'command', | |
| command: 'echo "test"', | |
| timeout: 60, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].hooks[0].timeout).toBe(60); | |
| }); | |
| it('should merge with existing Gemini hooks', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [{ type: 'command', command: 'echo "claude"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedLoadSettings.mockReturnValue({ | |
| merged: { | |
| hooks: { | |
| AfterTool: [ | |
| { | |
| hooks: [{ type: 'command', command: 'echo "existing"' }], | |
| }, | |
| ], | |
| }, | |
| }, | |
| setValue: mockSetValue, | |
| workspace: { path: '/test/project/.gemini' }, | |
| }); | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks).toHaveProperty('BeforeTool'); | |
| expect(migratedHooks).toHaveProperty('AfterTool'); | |
| expect(migratedHooks.AfterTool[0].hooks[0].command).toBe('echo "existing"'); | |
| expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe('echo "claude"'); | |
| }); | |
| it('should handle JSON with comments', async () => { | |
| const claudeSettingsWithComments = `{ | |
| // This is a comment | |
| "hooks": { | |
| /* Block comment */ | |
| "PreToolUse": [ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "echo test" // Inline comment | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| }`; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(claudeSettingsWithComments); | |
| await handleMigrateFromClaude(); | |
| expect(mockSetValue).toHaveBeenCalledWith( | |
| SettingScope.Workspace, | |
| 'hooks', | |
| expect.objectContaining({ | |
| BeforeTool: expect.any(Array), | |
| }), | |
| ); | |
| }); | |
| it('should handle malformed JSON gracefully', async () => { | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue('{ invalid json }'); | |
| await handleMigrateFromClaude(); | |
| expect(debugLoggerErrorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining('Error reading'), | |
| ); | |
| expect(mockSetValue).not.toHaveBeenCalled(); | |
| }); | |
| it('should log info when no hooks are found in Claude settings', async () => { | |
| const claudeSettings = { | |
| someOtherSetting: 'value', | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| 'No hooks found in Claude Code settings to migrate.', | |
| ); | |
| expect(mockSetValue).not.toHaveBeenCalled(); | |
| }); | |
| it('should handle setValue errors gracefully', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [{ type: 'command', command: 'echo "test"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| mockSetValue.mockImplementation(() => { | |
| throw new Error('Failed to save'); | |
| }); | |
| await handleMigrateFromClaude(); | |
| expect(debugLoggerErrorSpy).toHaveBeenCalledWith( | |
| 'Error saving migrated hooks: Failed to save', | |
| ); | |
| }); | |
| it('should handle hooks with matcher but no command', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| matcher: 'Edit', | |
| hooks: [ | |
| { | |
| type: 'command', | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].matcher).toBe('replace'); | |
| expect(migratedHooks.BeforeTool[0].hooks[0].type).toBe('command'); | |
| }); | |
| it('should handle empty hooks array', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks.BeforeTool[0].hooks).toEqual([]); | |
| }); | |
| it('should handle non-array event config gracefully', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: 'not an array', | |
| PostToolUse: [ | |
| { | |
| hooks: [{ type: 'command', command: 'echo "test"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| const migratedHooks = mockSetValue.mock.calls[0][2]; | |
| expect(migratedHooks).not.toHaveProperty('BeforeTool'); | |
| expect(migratedHooks).toHaveProperty('AfterTool'); | |
| }); | |
| it('should display migration instructions after successful migration', async () => { | |
| const claudeSettings = { | |
| hooks: { | |
| PreToolUse: [ | |
| { | |
| hooks: [{ type: 'command', command: 'echo "test"' }], | |
| }, | |
| ], | |
| }, | |
| }; | |
| mockedFs.existsSync.mockReturnValue(true); | |
| mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); | |
| await handleMigrateFromClaude(); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| '✓ Hooks successfully migrated to .gemini/settings.json', | |
| ); | |
| expect(debugLoggerLogSpy).toHaveBeenCalledWith( | |
| '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', | |
| ); | |
| }); | |
| }); | |