Skip to content

Commit cb57efb

Browse files
cahaselerclaude
andauthored
feat: add Windows path normalization hook (bug #7918 workaround) (#176)
* feat: add PowerShell guidance hook for Windows users Adds a PreToolUse hook that intercepts Linux/bash commands on Windows and either: - Auto-converts simple commands to PowerShell equivalents (cat→Get-Content, rm→Remove-Item, etc.) - Rejects complex commands with clear PowerShell syntax examples This reduces trial-and-error when Claude tries Linux commands that fail on Windows. Features: - 15+ auto-conversions for common commands (cat, rm, cp, mv, mkdir, touch, head, tail, etc.) - 20+ rejection patterns with detailed PowerShell alternatives (grep, sed, awk, chmod, etc.) - Only runs on Windows and only when explicitly enabled in config - Passes through PowerShell cmdlets unchanged - 42 tests covering all functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: guard main() with import.meta.main check Prevents hook from executing when imported for testing, matching pattern used by other hooks in the codebase. Addresses Codex P1 review feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use ESM import for dirname instead of require The require('node:path') call inside isHookEnabled() throws in ESM packages since require is undefined, causing the hook to always return false and never execute. Addresses second Codex P1 review feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add file path normalization to Windows hook Extends the PowerShell guidance hook to also normalize file paths in Edit, Write, Read, and MultiEdit tools on Windows. Converts forward slashes to backslashes, working around Claude Code bug #7918 where the Edit tool fails with "File has been unexpectedly modified" when paths use forward slashes on Windows. Changes: - Hook now intercepts file tools in addition to Bash - Paths with forward slashes are converted to backslashes - Added 7 new tests for path normalization scenarios - Updated setup documentation to explain both features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use correct Claude Code hook output format for PreToolUse The hook was using an incorrect JSON output format. Claude Code expects hookSpecificOutput with hookEventName, permissionDecision, and updatedInput fields for PreToolUse hooks that modify tool input. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify hook to path normalization only Remove command conversion logic since Git Bash provides Unix commands that work fine on Windows. Keep only the path normalization feature that works around Claude Code bug #7918. - Remove SIMPLE_CONVERSIONS and REJECTION_PATTERNS - Remove bash command interception - Keep file path normalization for Edit/Write/Read/MultiEdit tools - Update tests to match simplified functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: rename hook to windows-path-normalization Rename from powershell-guidance since the hook now only handles path normalization, not PowerShell command conversion. - Rename files: powershell-guidance.ts → windows-path-normalization.ts - Update function name: powershellGuidanceHook → windowsPathNormalizationHook - Update config key: powershell_guidance → windows_path_normalization - Remove Bash from hook matcher (no longer needed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Craig Haseler <cahaseler@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9e6f82f commit cb57efb

File tree

6 files changed

+355
-0
lines changed

6 files changed

+355
-0
lines changed

.cc-track/track.config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
"pre_tool_validation": {
3838
"enabled": true,
3939
"description": "Pre-tool validation for task files and branch protection"
40+
},
41+
"windows_path_normalization": {
42+
"enabled": true,
43+
"description": "Windows only: normalizes file paths to backslashes (workaround for Claude Code bug #7918). Must be explicitly enabled."
4044
}
4145
},
4246
"git": {

commands/setup-cc-track.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ Present feature options and let user choose what to enable:
175175
- ⚠️ Cons: Adds validation overhead to every web search operation
176176
- Ask: "Enable web search validation? (helps prevent unnecessary searches)"
177177

178+
**Windows Only:**
179+
- `powershell_guidance` - Windows compatibility hook with two functions:
180+
1. **Command conversion:** Auto-converts simple Linux commands (cat, rm, cp, mv, head, tail, etc.) to PowerShell
181+
2. **Path normalization:** Converts forward slashes to backslashes in file operations (workaround for Claude Code #7918)
182+
- ✅ Pros: Auto-converts simple Linux commands to PowerShell equivalents
183+
- ✅ Pros: Rejects complex commands with clear PowerShell alternatives and syntax examples
184+
- ✅ Pros: Prevents trial-and-error when Claude tries Linux commands that fail on Windows
185+
- ✅ Pros: Fixes "File has been unexpectedly modified" errors on Windows (Claude Code bug #7918)
186+
- ⚠️ Only useful on Windows - do NOT enable on macOS/Linux
187+
- Ask: (only if on Windows) "Enable Windows compatibility hook? (fixes path issues, auto-converts Linux commands)"
188+
- Note: This is a PreToolUse hook that intercepts Bash, Edit, Write, Read, and MultiEdit tools
189+
178190
### 4. Configuration File Creation
179191

180192
Based on user selections, create `.cc-track/track.config.json`:
@@ -196,6 +208,9 @@ Read the template from `${CLAUDE_PLUGIN_ROOT}/templates/track.config.json` and c
196208
},
197209
"pre_tool_validation": {
198210
"enabled": true/false
211+
},
212+
"powershell_guidance": {
213+
"enabled": true/false
199214
}
200215
},
201216
"features": {
@@ -317,10 +332,14 @@ Based on enabled features, configure hooks in Claude Code settings.
317332
Show user what hooks will be enabled:
318333
- `edit_validation` → PostToolUse hook
319334
- `pre_tool_validation` → PreToolUse hook
335+
- `powershell_guidance` → PreToolUse hook (Windows only)
320336

321337
Explain:
322338
- Hooks execute TypeScript files from `${CLAUDE_PLUGIN_ROOT}/hooks/`
323339
- User can disable individual hooks via `/config-track` later
340+
- The `powershell_guidance` hook intercepts Bash/Edit/Write/Read tools to:
341+
- Convert Linux commands to PowerShell or provide guidance
342+
- Normalize file paths to Windows backslash format (fixes Claude Code bug #7918)
324343

325344
### 8. Configure Claude Code Settings (if statusline enabled)
326345

hooks/hooks.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"name": "pre-tool-validation",
1212
"script": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-validation.ts",
1313
"description": "Validates branch protection and task file integrity before tool use"
14+
},
15+
{
16+
"name": "powershell-guidance",
17+
"script": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/powershell-guidance.ts",
18+
"description": "Windows only: Converts Linux commands to PowerShell or provides guidance"
1419
}
1520
]
1621
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// ABOUTME: Tests for the Windows path normalization hook
2+
// ABOUTME: Verifies path normalization for Windows (Claude Code bug #7918 workaround)
3+
4+
import { describe, expect, test } from 'bun:test';
5+
import { windowsPathNormalizationHook } from './windows-path-normalization';
6+
7+
describe('windowsPathNormalizationHook', () => {
8+
const mockEnabled = () => true;
9+
const mockDisabled = () => false;
10+
11+
describe('platform detection', () => {
12+
test('approves all tools on non-Windows platforms', async () => {
13+
const result = await windowsPathNormalizationHook(
14+
{ tool_name: 'Edit', tool_input: { file_path: 'D:/test/file.ts' } },
15+
{ isEnabled: mockEnabled, platform: 'linux' },
16+
);
17+
18+
expect(result.decision).toBe('approve');
19+
});
20+
21+
test('approves all tools on macOS', async () => {
22+
const result = await windowsPathNormalizationHook(
23+
{ tool_name: 'Edit', tool_input: { file_path: '/home/user/file.ts' } },
24+
{ isEnabled: mockEnabled, platform: 'darwin' },
25+
);
26+
27+
expect(result.decision).toBe('approve');
28+
});
29+
});
30+
31+
describe('config check', () => {
32+
test('approves all tools when hook is disabled', async () => {
33+
const result = await windowsPathNormalizationHook(
34+
{ tool_name: 'Edit', tool_input: { file_path: 'D:/test/file.ts' } },
35+
{ isEnabled: mockDisabled, platform: 'win32' },
36+
);
37+
38+
expect(result.decision).toBe('approve');
39+
});
40+
});
41+
42+
describe('file path normalization (workaround for Claude Code #7918)', () => {
43+
test('normalizes forward slashes to backslashes in Edit tool', async () => {
44+
const result = await windowsPathNormalizationHook(
45+
{ tool_name: 'Edit', tool_input: { file_path: 'D:/repos/project/file.ts', old_string: 'a', new_string: 'b' } },
46+
{ isEnabled: mockEnabled, platform: 'win32' },
47+
);
48+
49+
expect(result.decision).toBe('modify');
50+
expect(result.modified_tool_input?.file_path).toBe('D:\\repos\\project\\file.ts');
51+
expect(result.modified_tool_input?.old_string).toBe('a');
52+
});
53+
54+
test('normalizes forward slashes in Write tool', async () => {
55+
const result = await windowsPathNormalizationHook(
56+
{ tool_name: 'Write', tool_input: { file_path: 'C:/Users/test/file.txt', content: 'hello' } },
57+
{ isEnabled: mockEnabled, platform: 'win32' },
58+
);
59+
60+
expect(result.decision).toBe('modify');
61+
expect(result.modified_tool_input?.file_path).toBe('C:\\Users\\test\\file.txt');
62+
expect(result.modified_tool_input?.content).toBe('hello');
63+
});
64+
65+
test('normalizes forward slashes in Read tool', async () => {
66+
const result = await windowsPathNormalizationHook(
67+
{ tool_name: 'Read', tool_input: { file_path: 'D:/Development/pars/cc-track/src/index.ts' } },
68+
{ isEnabled: mockEnabled, platform: 'win32' },
69+
);
70+
71+
expect(result.decision).toBe('modify');
72+
expect(result.modified_tool_input?.file_path).toBe('D:\\Development\\pars\\cc-track\\src\\index.ts');
73+
});
74+
75+
test('normalizes forward slashes in MultiEdit tool', async () => {
76+
const result = await windowsPathNormalizationHook(
77+
{ tool_name: 'MultiEdit', tool_input: { file_path: 'src/lib/config.ts', edits: [] } },
78+
{ isEnabled: mockEnabled, platform: 'win32' },
79+
);
80+
81+
expect(result.decision).toBe('modify');
82+
expect(result.modified_tool_input?.file_path).toBe('src\\lib\\config.ts');
83+
});
84+
85+
test('approves file tools that already use backslashes', async () => {
86+
const result = await windowsPathNormalizationHook(
87+
{
88+
tool_name: 'Edit',
89+
tool_input: { file_path: 'D:\\repos\\project\\file.ts', old_string: 'a', new_string: 'b' },
90+
},
91+
{ isEnabled: mockEnabled, platform: 'win32' },
92+
);
93+
94+
expect(result.decision).toBe('approve');
95+
});
96+
97+
test('does not normalize paths on non-Windows platforms', async () => {
98+
const result = await windowsPathNormalizationHook(
99+
{ tool_name: 'Edit', tool_input: { file_path: '/home/user/file.ts', old_string: 'a', new_string: 'b' } },
100+
{ isEnabled: mockEnabled, platform: 'linux' },
101+
);
102+
103+
expect(result.decision).toBe('approve');
104+
});
105+
106+
test('does not normalize paths when hook is disabled', async () => {
107+
const result = await windowsPathNormalizationHook(
108+
{ tool_name: 'Edit', tool_input: { file_path: 'D:/repos/file.ts', old_string: 'a', new_string: 'b' } },
109+
{ isEnabled: mockDisabled, platform: 'win32' },
110+
);
111+
112+
expect(result.decision).toBe('approve');
113+
});
114+
});
115+
116+
describe('tool filtering', () => {
117+
test('approves non-file tools', async () => {
118+
const result = await windowsPathNormalizationHook(
119+
{ tool_name: 'Grep', tool_input: { pattern: 'test' } },
120+
{ isEnabled: mockEnabled, platform: 'win32' },
121+
);
122+
123+
expect(result.decision).toBe('approve');
124+
});
125+
126+
test('approves Bash tool (no command conversion)', async () => {
127+
const result = await windowsPathNormalizationHook(
128+
{ tool_name: 'Bash', tool_input: { command: 'cat file.txt' } },
129+
{ isEnabled: mockEnabled, platform: 'win32' },
130+
);
131+
132+
expect(result.decision).toBe('approve');
133+
});
134+
135+
test('approves file tools without file_path', async () => {
136+
const result = await windowsPathNormalizationHook(
137+
{ tool_name: 'Edit', tool_input: { other: 'value' } },
138+
{ isEnabled: mockEnabled, platform: 'win32' },
139+
);
140+
141+
expect(result.decision).toBe('approve');
142+
});
143+
});
144+
145+
describe('preserves other tool_input properties', () => {
146+
test('preserves all properties in modified output', async () => {
147+
const result = await windowsPathNormalizationHook(
148+
{
149+
tool_name: 'Edit',
150+
tool_input: {
151+
file_path: 'D:/test/file.ts',
152+
old_string: 'foo',
153+
new_string: 'bar',
154+
replace_all: true,
155+
},
156+
},
157+
{ isEnabled: mockEnabled, platform: 'win32' },
158+
);
159+
160+
expect(result.decision).toBe('modify');
161+
expect(result.modified_tool_input?.file_path).toBe('D:\\test\\file.ts');
162+
expect(result.modified_tool_input?.old_string).toBe('foo');
163+
expect(result.modified_tool_input?.new_string).toBe('bar');
164+
expect(result.modified_tool_input?.replace_all).toBe(true);
165+
});
166+
});
167+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// ABOUTME: PreToolUse hook for Windows that normalizes file paths to backslash format
2+
// ABOUTME: Works around Claude Code bug #7918 where Edit fails with forward slashes
3+
// ABOUTME: Only active when explicitly enabled in track.config.json
4+
5+
import { existsSync, readFileSync } from 'node:fs';
6+
import { dirname } from 'node:path';
7+
import { join } from 'node:path/posix';
8+
import { createLogger } from '../skills/cc-track-tools/lib/logger';
9+
10+
const logger = createLogger('windows-path-normalization');
11+
12+
interface HookInput {
13+
tool_name: string;
14+
tool_input: {
15+
file_path?: string;
16+
[key: string]: unknown;
17+
};
18+
}
19+
20+
interface HookResult {
21+
decision: 'block' | 'approve' | 'modify';
22+
reason?: string;
23+
modified_tool_input?: {
24+
file_path?: string;
25+
[key: string]: unknown;
26+
};
27+
}
28+
29+
// File tools that use file_path parameter
30+
const FILE_TOOLS = ['Edit', 'Write', 'Read', 'MultiEdit'];
31+
32+
// Load config to check if hook is enabled
33+
function isHookEnabled(): boolean {
34+
try {
35+
// Look for track.config.json in current directory or parents
36+
let searchPath = process.cwd();
37+
38+
while (true) {
39+
const configPath = join(searchPath, '.cc-track', 'track.config.json');
40+
if (existsSync(configPath)) {
41+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
42+
return config?.hooks?.windows_path_normalization?.enabled === true;
43+
}
44+
45+
const parentPath = dirname(searchPath);
46+
if (parentPath === searchPath) break;
47+
searchPath = parentPath;
48+
}
49+
50+
return false;
51+
} catch {
52+
return false;
53+
}
54+
}
55+
56+
// Main hook function
57+
export async function windowsPathNormalizationHook(
58+
input: HookInput,
59+
deps: {
60+
isEnabled?: () => boolean;
61+
platform?: string;
62+
} = {},
63+
): Promise<HookResult> {
64+
const isEnabled = deps.isEnabled || isHookEnabled;
65+
const platform = deps.platform || process.platform;
66+
67+
// Only run on Windows
68+
if (platform !== 'win32') {
69+
return { decision: 'approve' };
70+
}
71+
72+
// Only run if explicitly enabled
73+
if (!isEnabled()) {
74+
return { decision: 'approve' };
75+
}
76+
77+
// Handle file tools - normalize paths to backslashes on Windows
78+
// This works around Claude Code bug #7918 where Edit fails with forward slashes
79+
if (FILE_TOOLS.includes(input.tool_name)) {
80+
const filePath = input.tool_input.file_path;
81+
if (filePath && typeof filePath === 'string' && filePath.includes('/')) {
82+
const normalizedPath = filePath.replace(/\//g, '\\');
83+
logger.info('Normalizing file path to Windows format', {
84+
original: filePath,
85+
normalized: normalizedPath,
86+
tool: input.tool_name,
87+
});
88+
return {
89+
decision: 'modify',
90+
reason: `Normalized path separators to Windows format (workaround for Claude Code #7918)`,
91+
modified_tool_input: {
92+
...input.tool_input,
93+
file_path: normalizedPath,
94+
},
95+
};
96+
}
97+
}
98+
99+
// No changes needed
100+
return { decision: 'approve' };
101+
}
102+
103+
// CLI entry point for Claude Code hook system
104+
async function main() {
105+
try {
106+
const input = await new Promise<string>((resolve) => {
107+
let data = '';
108+
process.stdin.on('data', (chunk) => {
109+
data += chunk;
110+
});
111+
process.stdin.on('end', () => {
112+
resolve(data);
113+
});
114+
});
115+
116+
const hookInput: HookInput = JSON.parse(input);
117+
const result = await windowsPathNormalizationHook(hookInput);
118+
119+
if (result.decision === 'block') {
120+
// Output in Claude Code's expected format for blocking
121+
console.log(
122+
JSON.stringify({
123+
hookSpecificOutput: {
124+
hookEventName: 'PreToolUse',
125+
permissionDecision: 'deny',
126+
permissionDecisionReason: result.reason,
127+
},
128+
}),
129+
);
130+
process.exit(2); // Exit code 2 = block
131+
} else if (result.decision === 'modify') {
132+
// Output in Claude Code's expected format for modifying tool input
133+
console.log(
134+
JSON.stringify({
135+
hookSpecificOutput: {
136+
hookEventName: 'PreToolUse',
137+
permissionDecision: 'allow',
138+
updatedInput: result.modified_tool_input,
139+
},
140+
}),
141+
);
142+
process.exit(0);
143+
} else {
144+
// Approve - just exit cleanly
145+
process.exit(0);
146+
}
147+
} catch (error) {
148+
logger.error('Hook error', { error });
149+
// On error, don't block - let the command through
150+
process.exit(0);
151+
}
152+
}
153+
154+
// CLI entrypoint - only run when executed directly, not when imported
155+
if (import.meta.main) {
156+
main();
157+
}

0 commit comments

Comments
 (0)