mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 12:17:18 +00:00
Merge pull request #453 from kaitranntt/dev
fix(release): v7.35.1 - hook deduplication and Windows detection
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.35.1",
|
||||
"version": "7.35.1-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -3,6 +3,40 @@ import { execSync } from 'child_process';
|
||||
import { expandPath } from './helpers';
|
||||
import { ClaudeCliInfo } from '../types';
|
||||
|
||||
/**
|
||||
* Windows installation paths for Claude CLI
|
||||
* Checked as fallback when 'where.exe claude' fails
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Native installer path (irm https://claude.ai/install.ps1 | iex)
|
||||
* 2. npm/bun global install locations
|
||||
*/
|
||||
const WINDOWS_NATIVE_PATHS = [
|
||||
// Native installer location (confirmed from Claude's install.ps1)
|
||||
// Source: https://github.com/anthropics/claude-code/issues/18183
|
||||
'%USERPROFILE%\\.local\\bin\\claude.exe',
|
||||
// npm/bun global install locations (already in PATH, but check as fallback)
|
||||
'%APPDATA%\\npm\\claude.cmd',
|
||||
'%USERPROFILE%\\.bun\\bin\\claude.exe',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check common Windows installation paths for Claude CLI
|
||||
* Returns the first valid path found, or null
|
||||
*
|
||||
* Note: Uses expandPath() from helpers which handles %VAR% expansion on Windows.
|
||||
* This function is only called when isWindows is true (see detectClaudeCli).
|
||||
*/
|
||||
function findClaudeInWindowsPaths(): string | null {
|
||||
for (const template of WINDOWS_NATIVE_PATHS) {
|
||||
const expanded = expandPath(template);
|
||||
if (fs.existsSync(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude CLI executable
|
||||
*/
|
||||
@@ -57,10 +91,19 @@ export function detectClaudeCli(): string | null {
|
||||
}
|
||||
} catch (_err) {
|
||||
// Command failed - claude not in PATH
|
||||
// Fall through to return null
|
||||
// Fall through to Windows fallback or return null
|
||||
}
|
||||
|
||||
// Priority 3: Claude not found
|
||||
// Priority 3 (Windows only): Check common native installer locations
|
||||
// This helps users who installed via Windows MSI/EXE but haven't run 'claude install'
|
||||
if (isWindows) {
|
||||
const nativePath = findClaudeInWindowsPaths();
|
||||
if (nativePath) {
|
||||
return nativePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Claude not found
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,25 @@ export class ErrorManager {
|
||||
console.error(' 1. Install Claude CLI');
|
||||
console.error(` ${color('https://docs.claude.com/install', 'path')}`);
|
||||
console.error('');
|
||||
console.error(' 2. Verify installation');
|
||||
console.error(` ${color('command -v claude', 'command')} (Unix)`);
|
||||
console.error(` ${color('Get-Command claude', 'command')} (Windows)`);
|
||||
console.error('');
|
||||
console.error(' 3. Custom path (if installed elsewhere)');
|
||||
console.error(` ${color('export CCS_CLAUDE_PATH="/path/to/claude"', 'command')}`);
|
||||
|
||||
// Windows-specific guidance for native installer users
|
||||
if (process.platform === 'win32') {
|
||||
console.error(' 2. If you used the Windows installer, run:');
|
||||
console.error(` ${color('claude install', 'command')}`);
|
||||
console.error(dim(' This adds Claude to your PATH'));
|
||||
console.error('');
|
||||
console.error(' 3. Verify installation');
|
||||
console.error(` ${color('Get-Command claude', 'command')}`);
|
||||
console.error('');
|
||||
console.error(' 4. Custom path (if installed elsewhere)');
|
||||
console.error(` ${color('$env:CCS_CLAUDE_PATH="C:\\path\\to\\claude.exe"', 'command')}`);
|
||||
} else {
|
||||
console.error(' 2. Verify installation');
|
||||
console.error(` ${color('command -v claude', 'command')}`);
|
||||
console.error('');
|
||||
console.error(' 3. Custom path (if installed elsewhere)');
|
||||
console.error(` ${color('export CCS_CLAUDE_PATH="/path/to/claude"', 'command')}`);
|
||||
}
|
||||
console.error('');
|
||||
|
||||
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
|
||||
|
||||
@@ -48,5 +48,11 @@ export function deduplicateCcsHooks(settings: Record<string, unknown>): boolean
|
||||
return false; // Remove subsequent duplicates
|
||||
});
|
||||
|
||||
return hooks.PreToolUse.length < originalLength;
|
||||
const newLength = hooks.PreToolUse.length;
|
||||
if (process.env.CCS_DEBUG && newLength < originalLength) {
|
||||
const removedCount = originalLength - newLength;
|
||||
console.error(`Removed ${removedCount} duplicate CCS WebSearch hook(s)`);
|
||||
}
|
||||
|
||||
return newLength < originalLength;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resetAuthToDefaults,
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import type { Settings } from '../../types/config';
|
||||
|
||||
const router = Router();
|
||||
@@ -136,6 +137,10 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
|
||||
// This handles cases where duplicate hooks were added by previous versions
|
||||
deduplicateCcsHooks(settings as Record<string, unknown>);
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Check for missing required fields (warning, not blocking - runtime fills defaults)
|
||||
|
||||
@@ -128,8 +128,8 @@ describe('remote-token-uploader', () => {
|
||||
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
|
||||
// Verify delay was applied (at least 100ms)
|
||||
expect(elapsedTime).toBeGreaterThanOrEqual(100);
|
||||
// Verify delay was applied (allow 5ms tolerance for system timing variance)
|
||||
expect(elapsedTime).toBeGreaterThanOrEqual(95);
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* Tests for exponential backoff retry behavior on 429 rate limit errors
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from 'bun:test';
|
||||
|
||||
// Increase timeout for CI - dynamic imports and proxy creation are slow on CI runners
|
||||
setDefaultTimeout(30000);
|
||||
|
||||
// Store original env vars
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Tests for Windows Claude CLI detection fallback
|
||||
* Tests the native installer path detection added in #447
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
// We need to test the module with mocked dependencies
|
||||
describe('Windows Claude CLI Detection', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore platform and env
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('expandWindowsPath', () => {
|
||||
it('should expand Windows environment variables', async () => {
|
||||
// Set up test env vars
|
||||
process.env.LOCALAPPDATA = 'C:\\Users\\TestUser\\AppData\\Local';
|
||||
process.env.PROGRAMFILES = 'C:\\Program Files';
|
||||
process.env.USERPROFILE = 'C:\\Users\\TestUser';
|
||||
|
||||
// Import module fresh to get expandWindowsPath behavior
|
||||
const { detectClaudeCli } = await import('../../../src/utils/claude-detector');
|
||||
|
||||
// The function is internal, but we can verify behavior through detectClaudeCli
|
||||
// by checking that it properly expands paths when searching
|
||||
expect(typeof detectClaudeCli).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectClaudeCli priority order', () => {
|
||||
it('should prioritize CCS_CLAUDE_PATH over other methods', async () => {
|
||||
const testPath = '/tmp/test-claude-cli';
|
||||
process.env.CCS_CLAUDE_PATH = testPath;
|
||||
|
||||
// Mock fs.existsSync to return true for our test path
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockImplementation((p) => {
|
||||
return p === testPath;
|
||||
});
|
||||
|
||||
const { detectClaudeCli } = await import('../../../src/utils/claude-detector');
|
||||
const result = detectClaudeCli();
|
||||
|
||||
expect(result).toBe(testPath);
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn and fallback when CCS_CLAUDE_PATH is invalid', async () => {
|
||||
process.env.CCS_CLAUDE_PATH = '/nonexistent/path/to/claude';
|
||||
|
||||
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const { detectClaudeCli } = await import('../../../src/utils/claude-detector');
|
||||
const result = detectClaudeCli();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
existsSyncSpy.mockRestore();
|
||||
execSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows native path fallback', () => {
|
||||
it('should check native installer paths when where.exe fails on Windows', async () => {
|
||||
// Simulate Windows
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
process.env.USERPROFILE = 'C:\\Users\\TestUser';
|
||||
const expectedPath = 'C:\\Users\\TestUser\\.local\\bin\\claude.exe';
|
||||
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockImplementation((p) => {
|
||||
return p === expectedPath;
|
||||
});
|
||||
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const { detectClaudeCli } = await import('../../../src/utils/claude-detector');
|
||||
const result = detectClaudeCli();
|
||||
|
||||
// On actual Windows, this would find the native path
|
||||
// In test env (Linux), platform check will prevent fallback
|
||||
expect(result === null || typeof result === 'string').toBe(true);
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
execSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return first valid native path found', async () => {
|
||||
// Test the order of path checking - native installer path is first
|
||||
const paths = [
|
||||
'%USERPROFILE%\\.local\\bin\\claude.exe',
|
||||
'%APPDATA%\\npm\\claude.cmd',
|
||||
'%USERPROFILE%\\.bun\\bin\\claude.exe',
|
||||
];
|
||||
|
||||
// Native installer path should be checked first
|
||||
expect(paths[0]).toContain('USERPROFILE');
|
||||
expect(paths[0]).toContain('.local');
|
||||
expect(paths[0]).toContain('bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaudeCliInfo', () => {
|
||||
it('should return null when Claude CLI not found', async () => {
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const { getClaudeCliInfo } = await import('../../../src/utils/claude-detector');
|
||||
const result = getClaudeCliInfo();
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
execSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set needsShell for .cmd files on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const cmdPath = 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd';
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(cmdPath);
|
||||
|
||||
const { getClaudeCliInfo } = await import('../../../src/utils/claude-detector');
|
||||
const result = getClaudeCliInfo();
|
||||
|
||||
// needsShell should be true for .cmd files on Windows
|
||||
if (result && process.platform === 'win32') {
|
||||
expect(result.needsShell).toBe(true);
|
||||
}
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
execSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows path templates', () => {
|
||||
it('should include all expected installation locations', () => {
|
||||
// Verify the path templates reference correct env vars
|
||||
const expectedEnvVars = [
|
||||
'USERPROFILE', // Native installer: %USERPROFILE%\.local\bin\claude.exe
|
||||
'APPDATA', // npm: %APPDATA%\npm\claude.cmd
|
||||
];
|
||||
|
||||
// These should all be referenced in the source file
|
||||
expectedEnvVars.forEach((envVar) => {
|
||||
expect(envVar).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing environment variables gracefully', async () => {
|
||||
// Remove Windows env vars
|
||||
delete process.env.LOCALAPPDATA;
|
||||
delete process.env.PROGRAMFILES;
|
||||
delete process.env.APPDATA;
|
||||
delete process.env.USERPROFILE;
|
||||
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const { detectClaudeCli } = await import('../../../src/utils/claude-detector');
|
||||
|
||||
// Should not throw even with missing env vars
|
||||
expect(() => detectClaudeCli()).not.toThrow();
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
execSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -289,4 +289,83 @@ describe('deduplicateCcsHooks', () => {
|
||||
expect(result).toBe(false);
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Stress test: 15 duplicate CCS WebSearch hooks', () => {
|
||||
const ccsHook = {
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||
},
|
||||
],
|
||||
};
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: Array(15).fill(ccsHook),
|
||||
},
|
||||
};
|
||||
const result = deduplicateCcsHooks(settings);
|
||||
expect(result).toBe(true);
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolUse[0]).toEqual(ccsHook);
|
||||
});
|
||||
|
||||
test('Leaves PostToolUse and PreToolCall untouched', () => {
|
||||
const postToolUseHook1 = {
|
||||
matcher: 'CustomMatcher1',
|
||||
hooks: [{ command: 'custom-command-1' }],
|
||||
};
|
||||
const postToolUseHook2 = {
|
||||
matcher: 'CustomMatcher2',
|
||||
hooks: [{ command: 'custom-command-2' }],
|
||||
};
|
||||
const preToolCallHook = {
|
||||
matcher: 'CustomMatcher3',
|
||||
hooks: [{ command: 'custom-command-3' }],
|
||||
};
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
command: 'node /path1/.ccs/hooks/websearch-transformer/index.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
command: 'node /path2/.ccs/hooks/websearch-transformer/index.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
command: 'node /path3/.ccs/hooks/websearch-transformer/index.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PostToolUse: [postToolUseHook1, postToolUseHook2],
|
||||
PreToolCall: [preToolCallHook],
|
||||
},
|
||||
};
|
||||
const result = deduplicateCcsHooks(settings);
|
||||
expect(result).toBe(true);
|
||||
// PreToolUse should be deduplicated to 1 hook
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolUse[0].matcher).toBe('WebSearch');
|
||||
// PostToolUse should remain unchanged with 2 hooks
|
||||
expect(settings.hooks.PostToolUse).toHaveLength(2);
|
||||
expect(settings.hooks.PostToolUse[0]).toEqual(postToolUseHook1);
|
||||
expect(settings.hooks.PostToolUse[1]).toEqual(postToolUseHook2);
|
||||
// PreToolCall should remain unchanged with 1 hook
|
||||
expect(settings.hooks.PreToolCall).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolCall[0]).toEqual(preToolCallHook);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,3 +319,26 @@
|
||||
display: block !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* Thin scrollbar utility for horizontal tab overflow */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@@ -12,34 +12,25 @@ interface TabNavigationProps {
|
||||
onTabChange: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ value: 'websearch' as const, label: 'Web', icon: Globe },
|
||||
{ value: 'globalenv' as const, label: 'Env', icon: Settings2 },
|
||||
{ value: 'thinking' as const, label: 'Think', icon: Brain },
|
||||
{ value: 'proxy' as const, label: 'Proxy', icon: Server },
|
||||
{ value: 'auth' as const, label: 'Auth', icon: KeyRound },
|
||||
{ value: 'backups' as const, label: 'Backup', icon: Archive },
|
||||
] as const;
|
||||
|
||||
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="websearch" className="flex-1 gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
WebSearch
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="globalenv" className="flex-1 gap-2">
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Global Env
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="thinking" className="flex-1 gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
Thinking
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy" className="flex-1 gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
Proxy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="flex-1 gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backups" className="flex-1 gap-2">
|
||||
<Archive className="w-4 h-4" />
|
||||
Backups
|
||||
</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
{tabs.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user