claude-dev-env 1.26.5 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ import { writeFileSync, copyFileSync, chmodSync, mkdirSync, renameSync, lstatSync, unlinkSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+
6
+ export const KNOWN_GIT_HOOK_NAMES = Object.freeze([
7
+ 'pre-commit',
8
+ 'pre-push',
9
+ 'post-commit',
10
+ ]);
11
+
12
+ export const SHIM_DOCSTRING = (
13
+ 'Generated by claude-dev-env install.mjs. '
14
+ + 'Delegates to the matching Python module in this directory.'
15
+ );
16
+
17
+
18
+ const RENAME_RETRY_DELAYS_MS = Object.freeze([50, 100, 200]);
19
+
20
+
21
+ function renameSyncWithWindowsRetry(sourcePath, destinationPath) {
22
+ let lastError;
23
+ for (const delayMs of [0, ...RENAME_RETRY_DELAYS_MS]) {
24
+ if (delayMs > 0) {
25
+ const deadline = Date.now() + delayMs;
26
+ while (Date.now() < deadline) { /* spin */ }
27
+ }
28
+ try {
29
+ renameSync(sourcePath, destinationPath);
30
+ return;
31
+ } catch (renameError) {
32
+ if (renameError.code !== 'EPERM' && renameError.code !== 'EBUSY') {
33
+ throw renameError;
34
+ }
35
+ lastError = renameError;
36
+ }
37
+ }
38
+ try {
39
+ renameSync(sourcePath, destinationPath);
40
+ return;
41
+ } catch (secondRenameError) {
42
+ const partialPath = destinationPath + '.partial';
43
+ try {
44
+ copyFileSync(sourcePath, partialPath);
45
+ if (process.platform !== 'win32') {
46
+ chmodSync(partialPath, 0o755);
47
+ }
48
+ renameSync(partialPath, destinationPath);
49
+ } catch (copyError) {
50
+ try { unlinkSync(partialPath); } catch { /* ENOENT-safe */ }
51
+ try { unlinkSync(sourcePath); } catch { /* ENOENT-safe */ }
52
+ throw new Error(
53
+ `claude-dev-env: atomic rename failed (${secondRenameError.message}) `
54
+ + `and copy fallback also failed (${copyError.message})`,
55
+ { cause: copyError },
56
+ );
57
+ }
58
+ try {
59
+ unlinkSync(sourcePath);
60
+ } catch (cleanupError) {
61
+ if (cleanupError.code !== 'ENOENT') {
62
+ console.warn(`claude-dev-env: could not remove temp source after copy: ${cleanupError.message}`);
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+
69
+ function deriveModuleNameFromGitNativeHookName(gitNativeHookName) {
70
+ return gitNativeHookName.replaceAll('-', '_');
71
+ }
72
+
73
+
74
+ function buildShimContent(pythonModuleName) {
75
+ const shimContentHeader = '#!/usr/bin/env python3\n';
76
+ return (
77
+ shimContentHeader
78
+ + `"""${SHIM_DOCSTRING}"""\n`
79
+ + 'import sys\n'
80
+ + 'from pathlib import Path\n'
81
+ + '\n'
82
+ + 'shim_directory = Path(__file__).resolve().parent\n'
83
+ + 'if str(shim_directory) not in sys.path:\n'
84
+ + ' sys.path.insert(0, str(shim_directory))\n'
85
+ + '\n'
86
+ + `import ${pythonModuleName}\n`
87
+ + '\n'
88
+ + `sys.exit(${pythonModuleName}.main())\n`
89
+ );
90
+ }
91
+
92
+
93
+ function ensureHooksDirectoryExists(gitHooksDirectory) {
94
+ let preExistingStat = null;
95
+ try {
96
+ preExistingStat = lstatSync(gitHooksDirectory);
97
+ } catch (statError) {
98
+ if (statError.code !== 'ENOENT') {
99
+ throw statError;
100
+ }
101
+ }
102
+ if (preExistingStat !== null) {
103
+ if (preExistingStat.isSymbolicLink()) {
104
+ throw new Error(
105
+ `claude-dev-env: refusing to write hook shims — hooks directory is a symlink: ${gitHooksDirectory}`,
106
+ );
107
+ }
108
+ if (!preExistingStat.isDirectory()) {
109
+ throw new Error(
110
+ `claude-dev-env: refusing to write hook shims — hooks path is not a directory: ${gitHooksDirectory}`,
111
+ );
112
+ }
113
+ return;
114
+ }
115
+ mkdirSync(gitHooksDirectory, { recursive: false });
116
+ const postMkdirStat = lstatSync(gitHooksDirectory);
117
+ if (postMkdirStat.isSymbolicLink()) {
118
+ throw new Error(
119
+ `claude-dev-env: refusing to write hook shims — hooks directory is a symlink: ${gitHooksDirectory}`,
120
+ );
121
+ }
122
+ if (!postMkdirStat.isDirectory()) {
123
+ throw new Error(
124
+ `claude-dev-env: refusing to write hook shims — hooks path is not a directory: ${gitHooksDirectory}`,
125
+ );
126
+ }
127
+ }
128
+
129
+
130
+ export function writeGitHookShim({
131
+ gitHooksDirectory,
132
+ gitNativeHookName,
133
+ pythonModuleName,
134
+ }) {
135
+ ensureHooksDirectoryExists(gitHooksDirectory);
136
+ const shimPath = join(gitHooksDirectory, gitNativeHookName);
137
+ const shimTempPath = shimPath + '.tmp';
138
+ const shimContent = buildShimContent(pythonModuleName);
139
+ try { unlinkSync(shimTempPath); } catch { /* ENOENT-safe */ }
140
+ writeFileSync(shimTempPath, shimContent, { encoding: 'utf8', mode: 0o600 });
141
+ try {
142
+ const postWriteStat = lstatSync(shimTempPath);
143
+ if (postWriteStat.isSymbolicLink()) {
144
+ throw new Error(
145
+ `claude-dev-env: refusing to use temp shim — path became a symlink after write: ${shimTempPath}`,
146
+ );
147
+ }
148
+ try {
149
+ chmodSync(shimTempPath, 0o755);
150
+ } catch (chmodError) {
151
+ if (process.platform !== 'win32') {
152
+ throw chmodError;
153
+ }
154
+ }
155
+ renameSyncWithWindowsRetry(shimTempPath, shimPath);
156
+ } catch (postWriteError) {
157
+ try { unlinkSync(shimTempPath); } catch { /* ENOENT-safe */ }
158
+ throw postWriteError;
159
+ }
160
+ return shimPath;
161
+ }
162
+
163
+
164
+ export function writeAllGitHookShims({ gitHooksDirectory }) {
165
+ const createdShimPaths = [];
166
+ for (const gitNativeHookName of KNOWN_GIT_HOOK_NAMES) {
167
+ const pythonModuleName = deriveModuleNameFromGitNativeHookName(gitNativeHookName);
168
+ const shimPath = writeGitHookShim({
169
+ gitHooksDirectory,
170
+ gitNativeHookName,
171
+ pythonModuleName,
172
+ });
173
+ createdShimPaths.push(shimPath);
174
+ }
175
+ return createdShimPaths;
176
+ }
177
+
178
+
179
+ function readCurrentGlobalHooksPathViaExecSync() {
180
+ try {
181
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
182
+ encoding: 'utf8',
183
+ stdio: ['ignore', 'pipe', 'ignore'],
184
+ });
185
+ } catch (gitReadError) {
186
+ if (gitReadError.status === 1) {
187
+ return '';
188
+ }
189
+ throw gitReadError;
190
+ }
191
+ }
192
+
193
+
194
+ function writeGlobalHooksPathViaExecSync(targetHooksPath) {
195
+ execFileSync('git', ['config', '--global', 'core.hooksPath', targetHooksPath], { stdio: 'ignore' });
196
+ }
197
+
198
+
199
+ export function configureGlobalGitHooksPath({
200
+ targetGitHooksDirectory,
201
+ readCurrentHooksPath = readCurrentGlobalHooksPathViaExecSync,
202
+ writeHooksPath = writeGlobalHooksPathViaExecSync,
203
+ }) {
204
+ const normalizedTargetDirectory = targetGitHooksDirectory.replaceAll('\\', '/');
205
+ const currentRawValue = readCurrentHooksPath();
206
+ const currentTrimmedValue = (currentRawValue || '').trim();
207
+ const normalizedCurrentValue = currentTrimmedValue.replaceAll('\\', '/');
208
+ if (currentTrimmedValue === '') {
209
+ writeHooksPath(normalizedTargetDirectory);
210
+ return { action: 'set' };
211
+ }
212
+ if (normalizedCurrentValue === normalizedTargetDirectory) {
213
+ return { action: 'already-set' };
214
+ }
215
+ return {
216
+ action: 'skip',
217
+ reason: (
218
+ `core.hooksPath is already set to "${currentTrimmedValue}". `
219
+ + 'Skipping to avoid overriding an existing setup (e.g. husky, lefthook). '
220
+ + `To adopt claude-dev-env hooks, manually run: `
221
+ + `git config --global core.hooksPath ${JSON.stringify(normalizedTargetDirectory)}`
222
+ ),
223
+ };
224
+ }
225
+
226
+
227
+ export function installAllGitHooks({ claudeHomeDirectory }) {
228
+ const gitHooksDirectory = join(claudeHomeDirectory, 'hooks', 'git-hooks');
229
+ const createdShimPaths = writeAllGitHookShims({ gitHooksDirectory });
230
+ let hooksPathConfigurationResult;
231
+ try {
232
+ hooksPathConfigurationResult = configureGlobalGitHooksPath({
233
+ targetGitHooksDirectory: gitHooksDirectory,
234
+ });
235
+ } catch (gitConfigError) {
236
+ throw new Error(
237
+ `claude-dev-env: shims written but git config --global core.hooksPath failed: ${gitConfigError.message}`,
238
+ );
239
+ }
240
+ return {
241
+ gitHooksDirectory,
242
+ createdShimPaths,
243
+ hooksPathConfigurationResult,
244
+ };
245
+ }
@@ -0,0 +1,208 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, statSync, symlinkSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ writeGitHookShim,
9
+ writeAllGitHookShims,
10
+ configureGlobalGitHooksPath,
11
+ KNOWN_GIT_HOOK_NAMES,
12
+ } from './git_hooks_installer.mjs';
13
+
14
+
15
+ function makeTemporaryGitHooksDirectory() {
16
+ const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-git-hooks-test-'));
17
+ const gitHooksDirectory = join(temporaryRoot, 'git-hooks');
18
+ mkdirSync(gitHooksDirectory, { recursive: true });
19
+ return { temporaryRoot, gitHooksDirectory };
20
+ }
21
+
22
+
23
+ test('writeGitHookShim creates a file with the git-native name and imports the matching module', () => {
24
+ const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
25
+ try {
26
+ const shimPath = writeGitHookShim({
27
+ gitHooksDirectory,
28
+ gitNativeHookName: 'pre-commit',
29
+ pythonModuleName: 'pre_commit',
30
+ });
31
+ assert.equal(shimPath, join(gitHooksDirectory, 'pre-commit'));
32
+ assert.ok(existsSync(shimPath));
33
+ const shimContent = readFileSync(shimPath, 'utf8');
34
+ assert.ok(shimContent.startsWith('#!/usr/bin/env python3\n'));
35
+ assert.match(shimContent, /import\s+pre_commit/);
36
+ assert.match(shimContent, /pre_commit\.main\(\)/);
37
+ } finally {
38
+ rmSync(temporaryRoot, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+
43
+ test('writeAllGitHookShims creates one shim per known hook name', () => {
44
+ const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
45
+ try {
46
+ const createdShimPaths = writeAllGitHookShims({ gitHooksDirectory });
47
+ assert.equal(createdShimPaths.length, KNOWN_GIT_HOOK_NAMES.length);
48
+ for (const gitNativeHookName of KNOWN_GIT_HOOK_NAMES) {
49
+ const expectedShimPath = join(gitHooksDirectory, gitNativeHookName);
50
+ assert.ok(
51
+ existsSync(expectedShimPath),
52
+ `missing shim at ${expectedShimPath}`,
53
+ );
54
+ }
55
+ } finally {
56
+ rmSync(temporaryRoot, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+
61
+ test('configureGlobalGitHooksPath sets the path when nothing is currently configured', () => {
62
+ const commandsRun = [];
63
+ const gitConfigReaderReturningEmpty = () => '';
64
+ const gitConfigWriter = (value) => {
65
+ commandsRun.push(['set', value]);
66
+ };
67
+
68
+ const result = configureGlobalGitHooksPath({
69
+ targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
70
+ readCurrentHooksPath: gitConfigReaderReturningEmpty,
71
+ writeHooksPath: gitConfigWriter,
72
+ });
73
+
74
+ assert.equal(result.action, 'set');
75
+ assert.deepEqual(commandsRun, [['set', '/home/example/.claude/hooks/git-hooks']]);
76
+ });
77
+
78
+
79
+ test('configureGlobalGitHooksPath reports already-set when the current value matches the target', () => {
80
+ const commandsRun = [];
81
+ const gitConfigReaderReturningOurPath = () => '/home/example/.claude/hooks/git-hooks';
82
+ const gitConfigWriter = (value) => {
83
+ commandsRun.push(['set', value]);
84
+ };
85
+
86
+ const result = configureGlobalGitHooksPath({
87
+ targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
88
+ readCurrentHooksPath: gitConfigReaderReturningOurPath,
89
+ writeHooksPath: gitConfigWriter,
90
+ });
91
+
92
+ assert.equal(result.action, 'already-set');
93
+ assert.deepEqual(commandsRun, []);
94
+ });
95
+
96
+
97
+ test('configureGlobalGitHooksPath skips and reports reason when a foreign path is already configured', () => {
98
+ const commandsRun = [];
99
+ const gitConfigReaderReturningHuskyPath = () => '/home/example/project/.husky';
100
+ const gitConfigWriter = (value) => {
101
+ commandsRun.push(['set', value]);
102
+ };
103
+
104
+ const result = configureGlobalGitHooksPath({
105
+ targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
106
+ readCurrentHooksPath: gitConfigReaderReturningHuskyPath,
107
+ writeHooksPath: gitConfigWriter,
108
+ });
109
+
110
+ assert.equal(result.action, 'skip');
111
+ assert.match(result.reason, /\.husky/);
112
+ assert.deepEqual(commandsRun, []);
113
+ });
114
+
115
+
116
+ test('configureGlobalGitHooksPath normalizes trailing whitespace before comparing current to target', () => {
117
+ const gitConfigReaderReturningOurPathWithNewline = () => '/home/example/.claude/hooks/git-hooks\n';
118
+ const gitConfigWriter = () => {};
119
+
120
+ const result = configureGlobalGitHooksPath({
121
+ targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
122
+ readCurrentHooksPath: gitConfigReaderReturningOurPathWithNewline,
123
+ writeHooksPath: gitConfigWriter,
124
+ });
125
+
126
+ assert.equal(result.action, 'already-set');
127
+ });
128
+
129
+
130
+ test('configureGlobalGitHooksPath detects already-set when target has Windows backslashes and stored value has forward slashes', () => {
131
+ const commandsRun = [];
132
+ const gitConfigReaderReturningForwardSlashPath = () => 'C:/Users/example/.claude/hooks/git-hooks';
133
+ const gitConfigWriter = (value) => {
134
+ commandsRun.push(value);
135
+ };
136
+
137
+ const result = configureGlobalGitHooksPath({
138
+ targetGitHooksDirectory: 'C:\\Users\\example\\.claude\\hooks\\git-hooks',
139
+ readCurrentHooksPath: gitConfigReaderReturningForwardSlashPath,
140
+ writeHooksPath: gitConfigWriter,
141
+ });
142
+
143
+ assert.equal(result.action, 'already-set');
144
+ assert.deepEqual(commandsRun, []);
145
+ });
146
+
147
+
148
+ test('configureGlobalGitHooksPath writes forward-slash path when setting on Windows', () => {
149
+ const writtenPaths = [];
150
+ const gitConfigReaderReturningEmpty = () => '';
151
+ const gitConfigWriter = (value) => {
152
+ writtenPaths.push(value);
153
+ };
154
+
155
+ configureGlobalGitHooksPath({
156
+ targetGitHooksDirectory: 'C:\\Users\\example\\.claude\\hooks\\git-hooks',
157
+ readCurrentHooksPath: gitConfigReaderReturningEmpty,
158
+ writeHooksPath: gitConfigWriter,
159
+ });
160
+
161
+ assert.deepEqual(writtenPaths, ['C:/Users/example/.claude/hooks/git-hooks']);
162
+ });
163
+
164
+
165
+ test('writeGitHookShim output is executable on POSIX (mode includes user-execute bit)', () => {
166
+ if (process.platform === 'win32') {
167
+ return;
168
+ }
169
+ const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
170
+ try {
171
+ const shimPath = writeGitHookShim({
172
+ gitHooksDirectory,
173
+ gitNativeHookName: 'pre-commit',
174
+ pythonModuleName: 'pre_commit',
175
+ });
176
+ const stats = statSync(shimPath);
177
+ const userExecuteBit = 0o100;
178
+ assert.ok((stats.mode & userExecuteBit) !== 0, 'shim missing user-execute bit');
179
+ } finally {
180
+ rmSync(temporaryRoot, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+
185
+ test('writeGitHookShim rejects hooks directory that is a symlink (loopP5c-5)', () => {
186
+ if (process.platform === 'win32') {
187
+ return;
188
+ }
189
+ const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-git-hooks-test-'));
190
+ try {
191
+ const realDirectory = join(temporaryRoot, 'real-hooks');
192
+ const symlinkPath = join(temporaryRoot, 'symlink-hooks');
193
+ mkdirSync(realDirectory, { recursive: true });
194
+ symlinkSync(realDirectory, symlinkPath);
195
+ assert.throws(
196
+ () => writeGitHookShim({
197
+ gitHooksDirectory: symlinkPath,
198
+ gitNativeHookName: 'pre-commit',
199
+ pythonModuleName: 'pre_commit',
200
+ }),
201
+ (err) => err.message.includes('symlink'),
202
+ );
203
+ } finally {
204
+ rmSync(temporaryRoot, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+
package/bin/install.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
4
4
  import { join, dirname, resolve, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
- import { execSync } from 'node:child_process';
6
+ import { execSync, execFileSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createRequire } from 'node:module';
9
+ import { installAllGitHooks } from './git_hooks_installer.mjs';
9
10
 
10
11
  const CLAUDE_HOME = join(homedir(), '.claude');
11
12
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -355,6 +356,27 @@ function install(selectedGroups) {
355
356
  console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
356
357
  summary.hookGroups = totalHookGroups;
357
358
  console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
359
+
360
+ console.warn(
361
+ ' Warning: git hook installation sets core.hooksPath globally — '
362
+ + 'the hook will run in every git repo on this machine.',
363
+ );
364
+ const gitHookInstallationResult = installAllGitHooks({ claudeHomeDirectory: CLAUDE_HOME });
365
+ summary.gitHooks = {
366
+ shimPaths: gitHookInstallationResult.createdShimPaths,
367
+ hooksPathConfiguration: gitHookInstallationResult.hooksPathConfigurationResult,
368
+ };
369
+ const hooksPathConfigurationAction = gitHookInstallationResult.hooksPathConfigurationResult.action;
370
+ if (hooksPathConfigurationAction === 'set') {
371
+ allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
372
+ console.log(` Git hooks: configured core.hooksPath -> ${gitHookInstallationResult.gitHooksDirectory}`);
373
+ } else if (hooksPathConfigurationAction === 'already-set') {
374
+ allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
375
+ console.log(' Git hooks: core.hooksPath already points to claude-dev-env, no change');
376
+ } else {
377
+ console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
378
+ }
379
+ console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (pre-commit, pre-push, post-commit)`);
358
380
  }
359
381
  const claudeHubSource = join(PACKAGE_ROOT, 'CLAUDE.md');
360
382
  if (existsSync(claudeHubSource)) {
@@ -387,6 +409,50 @@ function install(selectedGroups) {
387
409
  console.log(` python: ${pythonCommand}\n`);
388
410
  }
389
411
 
412
+ function normalizePathForComparison(rawPath) {
413
+ return rawPath.trim().replaceAll('\\', '/');
414
+ }
415
+
416
+
417
+ function pathsAreEquivalent(storedPath, installedPath) {
418
+ const normalizedStored = normalizePathForComparison(storedPath);
419
+ const normalizedInstalled = normalizePathForComparison(installedPath);
420
+ if (normalizedStored === normalizedInstalled) {
421
+ return true;
422
+ }
423
+ const isMaybeCaseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
424
+ return isMaybeCaseInsensitive && normalizedStored.toLowerCase() === normalizedInstalled.toLowerCase();
425
+ }
426
+
427
+
428
+ function unsetGlobalGitHooksPathIfOurs() {
429
+ const installedGitHooksDirectory = join(CLAUDE_HOME, 'hooks', 'git-hooks');
430
+ let currentHooksPath = '';
431
+ try {
432
+ currentHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
433
+ encoding: 'utf8',
434
+ stdio: ['ignore', 'pipe', 'pipe'],
435
+ }).trim();
436
+ } catch (gitReadError) {
437
+ if (gitReadError.status === 1) {
438
+ return;
439
+ }
440
+ const stderrDetail = gitReadError.stderr ? ` stderr: ${gitReadError.stderr.trim()}` : '';
441
+ console.warn(` Git hooks: could not read core.hooksPath during uninstall (${gitReadError.message}${stderrDetail}) — hooks path may need manual cleanup`);
442
+ return;
443
+ }
444
+ if (!pathsAreEquivalent(currentHooksPath, installedGitHooksDirectory)) {
445
+ return;
446
+ }
447
+ try {
448
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
449
+ console.log(' Git hooks: unset global core.hooksPath');
450
+ } catch (gitUnsetError) {
451
+ console.warn(` Git hooks: could not unset core.hooksPath (${gitUnsetError.message})`);
452
+ }
453
+ }
454
+
455
+
390
456
  function uninstall() {
391
457
  console.log(`\nUninstalling ${PACKAGE_NAME}...\n`);
392
458
  if (!existsSync(MANIFEST_FILE)) {
@@ -421,6 +487,7 @@ function uninstall() {
421
487
  console.log(' Hook entries removed from settings.json');
422
488
  }
423
489
  }
490
+ unsetGlobalGitHooksPathIfOurs();
424
491
  unlinkSync(MANIFEST_FILE);
425
492
  for (const directory of [...CONTENT_DIRECTORIES, 'skills', 'hooks']) {
426
493
  const dirPath = join(CLAUDE_HOME, directory);
@@ -3,7 +3,9 @@ import datetime
3
3
  import json
4
4
  import os
5
5
  import re
6
+ import subprocess
6
7
  import sys
8
+ import tempfile
7
9
  from pathlib import Path
8
10
 
9
11
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
@@ -15,10 +17,59 @@ def gh_redirect_is_active() -> bool:
15
17
  env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
16
18
  return env_var_value in GH_REDIRECT_ACTIVE_TRUTHY_VALUES
17
19
 
18
- # Projects where git reset --hard is explicitly allowed by the user.
19
- # Add your own project paths here, e.g.:
20
- # os.path.normpath("C:/Users/you/your-project"),
21
- ALLOW_GIT_RESET_HARD_PROJECTS: list[str] = []
20
+ def directory_is_ephemeral(directory_path: str) -> bool:
21
+ ephemeral_auto_allow_disabled_env_var = "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
22
+ truthy_string_values = frozenset({"1", "true", "yes", "on"})
23
+ if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
24
+ return False
25
+ forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
26
+ all_ephemeral_path_segments = ("/worktrees/", "/worktree/", "/tmp/", "/temp/")
27
+ for each_segment in all_ephemeral_path_segments:
28
+ if each_segment in forward_slash_normalized_directory_path + "/":
29
+ return True
30
+ system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/").lower()
31
+ if forward_slash_normalized_directory_path.startswith(system_temporary_root + "/") or forward_slash_normalized_directory_path == system_temporary_root:
32
+ return True
33
+ try:
34
+ git_rev_parse_completion = subprocess.run(
35
+ ["git", "rev-parse", "--git-dir"],
36
+ cwd=directory_path,
37
+ capture_output=True,
38
+ text=True,
39
+ check=False,
40
+ )
41
+ except (OSError, subprocess.SubprocessError):
42
+ return False
43
+ if git_rev_parse_completion.returncode != 0:
44
+ return False
45
+ git_directory_path_normalized = git_rev_parse_completion.stdout.strip().replace("\\", "/").lower()
46
+ return "/.git/worktrees/" in git_directory_path_normalized or "/worktrees/" in git_directory_path_normalized
47
+
48
+
49
+ def load_allow_git_reset_hard_projects() -> list[str]:
50
+ allow_git_reset_hard_settings_key = "allowGitResetHardProjects"
51
+ settings_path = Path(CLAUDE_DIRECTORY_PATH) / "settings.json"
52
+ try:
53
+ raw_settings_text = settings_path.read_text(encoding="utf-8")
54
+ except OSError:
55
+ return []
56
+ try:
57
+ parsed_settings = json.loads(raw_settings_text)
58
+ except json.JSONDecodeError:
59
+ return []
60
+ if not isinstance(parsed_settings, dict):
61
+ return []
62
+ hooks_section = parsed_settings.get("hooks")
63
+ if not isinstance(hooks_section, dict):
64
+ return []
65
+ raw_allow_list = hooks_section.get(allow_git_reset_hard_settings_key)
66
+ if not isinstance(raw_allow_list, list):
67
+ return []
68
+ return [
69
+ each_project_path
70
+ for each_project_path in raw_allow_list
71
+ if isinstance(each_project_path, str)
72
+ ]
22
73
 
23
74
  DESTRUCTIVE_BASH_PATTERNS = [
24
75
  (re.compile(r'\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r', re.IGNORECASE), "rm -rf (destructive recursive forced delete)"),
@@ -136,15 +187,17 @@ def main() -> None:
136
187
 
137
188
  # Allow git reset --hard in explicitly approved projects (case-insensitive for Windows drive letters)
138
189
  if matched_description is not None and "git reset --hard" in matched_description:
139
- cwd = os.path.normpath(os.getcwd()).lower()
140
- command_lower = command.lower()
141
- for allowed_project in ALLOW_GIT_RESET_HARD_PROJECTS:
142
- allowed_lower = allowed_project.lower()
143
- if cwd.startswith(allowed_lower):
190
+ current_working_directory = os.getcwd()
191
+ if directory_is_ephemeral(current_working_directory):
192
+ sys.exit(0)
193
+ current_working_directory_lowercased = os.path.normpath(current_working_directory).lower()
194
+ for allowed_project in load_allow_git_reset_hard_projects():
195
+ allowed_project_lowercased = os.path.normpath(allowed_project).lower()
196
+ if current_working_directory_lowercased.startswith(allowed_project_lowercased):
144
197
  sys.exit(0)
145
198
  # Also check the cd target in the command itself
146
199
  for path_match in re.findall(r'cd\s+"([^"]+)"', command):
147
- if os.path.normpath(path_match).lower().startswith(allowed_lower):
200
+ if os.path.normpath(path_match).lower().startswith(allowed_project_lowercased):
148
201
  sys.exit(0)
149
202
 
150
203
  if matched_description is not None: