claude-dev-env 8.54.8 → 8.55.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.
Files changed (28) hide show
  1. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +87 -9
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +4 -1
  3. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +94 -4
  4. package/bin/install.agents-home.test.mjs +2 -0
  5. package/bin/install.codex-rules.test.mjs +4 -0
  6. package/bin/install.cursor-rules.test.mjs +1 -0
  7. package/bin/install.mjs +19 -20
  8. package/bin/install.profile-settings.test.mjs +77 -0
  9. package/bin/install.profiles.test.mjs +2 -2
  10. package/bin/install.prune.test.mjs +2 -0
  11. package/bin/install.question-presentation.test.mjs +2 -0
  12. package/bin/install.settings-defaults.test.mjs +8 -3
  13. package/bin/install.shared-settings.test.mjs +1 -0
  14. package/bin/install.test.mjs +7 -3
  15. package/bin/install.transaction.test.mjs +14 -0
  16. package/bin/install.uninstall-transaction.test.mjs +4 -0
  17. package/bin/install.update-keeps-hooks.test.mjs +2 -0
  18. package/bin/merge_profile_settings.mjs +372 -0
  19. package/bin/merge_profile_settings.test.mjs +208 -0
  20. package/installable-surfaces.manifest.json +2 -1
  21. package/package.json +3 -2
  22. package/profile-settings/config/__init__.py +1 -0
  23. package/profile-settings/config/session_title_gate_constants.py +15 -0
  24. package/profile-settings/session_title_status_gate.py +77 -0
  25. package/profile-settings/test_session_title_status_gate.py +101 -0
  26. package/scripts/dev_env_scripts_constants/private_term_constants.py +8 -4
  27. package/scripts/private_term_scan.py +15 -3
  28. package/scripts/tests/test_private_term_scan.py +81 -8
@@ -406,7 +406,9 @@ test('mergeHooksIntoSettings expands residual $HOME in preserved user hooks', ()
406
406
  ],
407
407
  },
408
408
  };
409
- mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
409
+ mergeHooksIntoSettings(
410
+ settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe', null, 'C:/Users/x',
411
+ );
410
412
  const allCommands = settings.hooks.SessionStart[0].hooks.map(eachHook => eachHook.command);
411
413
  assert.ok(
412
414
  allCommands.includes(
@@ -2980,7 +2982,7 @@ test('the help output states that the installer reads only flags', () => {
2980
2982
  const helpRun = spawnSync(
2981
2983
  process.execPath,
2982
2984
  [fileURLToPath(new URL('./install.mjs', import.meta.url)), '--help'],
2983
- { encoding: 'utf8', env: process.env },
2985
+ { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined } },
2984
2986
  );
2985
2987
 
2986
2988
  assert.equal(helpRun.status, 0, helpRun.stderr);
@@ -3024,6 +3026,7 @@ test('a failed update preserves an existing skills lookup pointer', () => {
3024
3026
  writeFileSync(join(homeDirectory, '.gitconfig'), '');
3025
3027
  const environment = {
3026
3028
  ...process.env,
3029
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
3027
3030
  HOME: homeDirectory,
3028
3031
  USERPROFILE: homeDirectory,
3029
3032
  CLAUDE_CONFIG_DIR: '',
@@ -3075,6 +3078,7 @@ function runPstackInstaller(homeDirectory, extraArguments, environmentOverrides
3075
3078
  encoding: 'utf8',
3076
3079
  env: {
3077
3080
  ...process.env,
3081
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
3078
3082
  HOME: homeDirectory,
3079
3083
  USERPROFILE: homeDirectory,
3080
3084
  CODEX_HOME: join(homeDirectory, '.codex'),
@@ -3390,7 +3394,7 @@ test('a failing usage-wrapup command is reported and the install still succeeds'
3390
3394
  test('the help output names the usage-wrapup opt-out beside the pstack one', () => {
3391
3395
  const helpRun = spawnSync(process.execPath, [PSTACK_TEST_INSTALLER_PATH, '--help'], {
3392
3396
  encoding: 'utf8',
3393
- env: process.env,
3397
+ env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined },
3394
3398
  });
3395
3399
 
3396
3400
  assert.equal(helpRun.status, 0, helpRun.stderr);
@@ -85,6 +85,8 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
85
85
  mkdirSync(priorHooksDirectory, { recursive: true });
86
86
  const env = {
87
87
  ...process.env,
88
+ CLAUDE_CONFIG_DIR: undefined,
89
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
88
90
  CDE_INSTALL_PSTACK: '0',
89
91
  CDE_INSTALL_USAGE_WRAPUP: '0',
90
92
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -148,6 +150,8 @@ test('capture and restore recover an additional host settings file', () => {
148
150
  const io = {
149
151
  env: {
150
152
  ...process.env,
153
+ CLAUDE_CONFIG_DIR: undefined,
154
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
151
155
  CDE_INSTALL_PSTACK: '0',
152
156
  CDE_INSTALL_USAGE_WRAPUP: '0',
153
157
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -184,6 +188,8 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
184
188
  writeFileSync(gitConfigPath, '');
185
189
  const env = {
186
190
  ...process.env,
191
+ CLAUDE_CONFIG_DIR: undefined,
192
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
187
193
  CDE_INSTALL_PSTACK: '0',
188
194
  CDE_INSTALL_USAGE_WRAPUP: '0',
189
195
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -239,6 +245,8 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
239
245
  const io = {
240
246
  env: {
241
247
  ...process.env,
248
+ CLAUDE_CONFIG_DIR: undefined,
249
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
242
250
  CDE_INSTALL_PSTACK: '0',
243
251
  CDE_INSTALL_USAGE_WRAPUP: '0',
244
252
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -283,6 +291,8 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
283
291
  function runInstaller(homeDirectory, extraArguments, options = {}) {
284
292
  const childEnvironment = {
285
293
  ...process.env,
294
+ CLAUDE_CONFIG_DIR: undefined,
295
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
286
296
  CDE_INSTALL_PSTACK: '0',
287
297
  CDE_INSTALL_USAGE_WRAPUP: '0',
288
298
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -463,6 +473,8 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
463
473
  writeGlobalCoreHooksPath(priorHooksPath, {
464
474
  env: {
465
475
  ...process.env,
476
+ CLAUDE_CONFIG_DIR: undefined,
477
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
466
478
  CDE_INSTALL_PSTACK: '0',
467
479
  CDE_INSTALL_USAGE_WRAPUP: '0',
468
480
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -481,6 +493,8 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
481
493
  const restored = readGlobalCoreHooksPath({
482
494
  env: {
483
495
  ...process.env,
496
+ CLAUDE_CONFIG_DIR: undefined,
497
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
484
498
  CDE_INSTALL_PSTACK: '0',
485
499
  CDE_INSTALL_USAGE_WRAPUP: '0',
486
500
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -71,6 +71,8 @@ function writeFileWithParents(filePath, contents) {
71
71
  function runInstaller(homeDirectory, extraArguments, options = {}) {
72
72
  const childEnvironment = {
73
73
  ...process.env,
74
+ CLAUDE_CONFIG_DIR: undefined,
75
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
74
76
  CDE_INSTALL_PSTACK: '0',
75
77
  CDE_INSTALL_USAGE_WRAPUP: '0',
76
78
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -306,6 +308,8 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
306
308
  const io = {
307
309
  env: {
308
310
  ...process.env,
311
+ CLAUDE_CONFIG_DIR: undefined,
312
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
309
313
  CDE_INSTALL_PSTACK: '0',
310
314
  CDE_INSTALL_USAGE_WRAPUP: '0',
311
315
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -22,6 +22,8 @@ const REGISTERED_HOOK_RELATIVE_PATHS = [
22
22
  function installerEnvironment(homeDirectory) {
23
23
  const childEnvironment = {
24
24
  ...process.env,
25
+ CLAUDE_CONFIG_DIR: undefined,
26
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
25
27
  CDE_INSTALL_PSTACK: '0',
26
28
  CDE_INSTALL_USAGE_WRAPUP: '0',
27
29
  CDE_INSTALL_SUBAGENT_MODELS: '0',
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Add the declared profile settings entries to every Claude profile settings.json.
3
+ *
4
+ * Each declared entry is one item in a list at a key path, such as one rule in
5
+ * `permissions.allow`. The merge adds an item only when the list lacks it, so a
6
+ * second run changes nothing and every other key stays as the file holds it.
7
+ * Before the first change to a file, the merge copies that file to a
8
+ * timestamped `.bak` beside it.
9
+ *
10
+ * Run `node merge_profile_settings.mjs --dry-run` to list the additions for
11
+ * every profile on this machine without writing anything.
12
+ */
13
+
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { expandHomeDirectoryTokens } from './expand_home_directory_tokens.mjs';
20
+ import { SETTINGS_FILE_NAME } from './install-constants.mjs';
21
+ import {
22
+ CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE,
23
+ DEFAULT_CLAUDE_DIRECTORY_NAME,
24
+ normalizePathForComparison,
25
+ } from './resolve-install-root.mjs';
26
+ import { resolveProfilesRootDirectory } from './select-install-targets.mjs';
27
+
28
+ export const DRY_RUN_FLAG = '--dry-run';
29
+ export const SESSION_TITLE_GATE_FILE_NAME = 'session_title_status_gate.py';
30
+ export const SESSION_TITLE_GATE_SOURCE_DIRECTORY = fileURLToPath(new URL('../profile-settings/', import.meta.url));
31
+ export const ALL_SESSION_TITLE_GATE_RELATIVE_PATHS = Object.freeze([
32
+ SESSION_TITLE_GATE_FILE_NAME,
33
+ join('config', '__init__.py'),
34
+ join('config', 'session_title_gate_constants.py'),
35
+ ]);
36
+ export const SESSION_TITLE_GATE_COMMAND = `python3 ~/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
37
+ export const AUTO_MODE_DEFAULTS_ENTRY = '$defaults';
38
+
39
+ const DEFAULT_SETTINGS_INDENT = ' ';
40
+ const DEFAULT_HOST_CONFIGURATION_INDENT = ' ';
41
+
42
+ /**
43
+ * @typedef {{ keyPath: string[], items: unknown[] }} DeclaredListEntries
44
+ */
45
+
46
+ /** @type {readonly DeclaredListEntries[]} */
47
+ export const DECLARED_PROFILE_SETTINGS = Object.freeze([
48
+ {
49
+ keyPath: ['hooks', 'Stop'],
50
+ items: [
51
+ {
52
+ hooks: [
53
+ { type: 'command', command: SESSION_TITLE_GATE_COMMAND, timeout: 10 },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ {
59
+ keyPath: ['permissions', 'allow'],
60
+ items: [
61
+ 'mcp__claude-code-remote__set_session_title',
62
+ 'mcp__ccd_session_mgmt__set_session_title',
63
+ `Bash(${SESSION_TITLE_GATE_COMMAND}:*)`,
64
+ 'Bash(python ~/.claude/scripts/account_broker.py run:*)',
65
+ ],
66
+ },
67
+ {
68
+ keyPath: ['autoMode', 'allow'],
69
+ items: [
70
+ AUTO_MODE_DEFAULTS_ENTRY,
71
+ 'Setting this session\'s title with the set_session_title MCP tool '
72
+ + '(mcp__claude-code-remote__set_session_title or mcp__ccd_session_mgmt__set_session_title) is allowed, '
73
+ + 'and so is a live test run of the account broker with '
74
+ + '`python ~/.claude/scripts/account_broker.py run`: the user installed both tools for these sessions',
75
+ ],
76
+ },
77
+ ]);
78
+
79
+ /**
80
+ * List every Claude profile settings.json on this machine.
81
+ *
82
+ * The main profile and the `CLAUDE_CONFIG_DIR` profile count when their
83
+ * directory exists, so a missing settings.json there is created. A directory
84
+ * under the profiles root counts only when it already holds a settings.json,
85
+ * which skips agents homes and other non-profile folders kept beside them.
86
+ *
87
+ * @param {{ homeDirectory: string, environment?: Record<string, string | undefined> }} options
88
+ * @returns {string[]} Absolute settings.json paths, one per profile.
89
+ */
90
+ export function discoverProfileSettingsPaths(options) {
91
+ const environment = options.environment ?? {};
92
+ const allProfileDirectories = [join(options.homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME)];
93
+ const configDirectory = environment[CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE]?.trim();
94
+ if (configDirectory) {
95
+ allProfileDirectories.push(configDirectory);
96
+ }
97
+ const profilesRoot = resolveProfilesRootDirectory({
98
+ homeDirectory: options.homeDirectory,
99
+ environment,
100
+ });
101
+ const allSettingsPaths = allProfileDirectories
102
+ .filter((eachDirectory) => existsSync(eachDirectory))
103
+ .map((eachDirectory) => resolve(eachDirectory, SETTINGS_FILE_NAME));
104
+ if (existsSync(profilesRoot)) {
105
+ for (const eachEntry of readdirSync(profilesRoot, { withFileTypes: true })) {
106
+ const settingsPath = join(profilesRoot, eachEntry.name, SETTINGS_FILE_NAME);
107
+ if (eachEntry.isDirectory() && existsSync(settingsPath)) {
108
+ allSettingsPaths.push(resolve(settingsPath));
109
+ }
110
+ }
111
+ }
112
+ const seenKeys = new Set();
113
+ return allSettingsPaths.filter((eachPath) => {
114
+ const comparisonKey = normalizePathForComparison(eachPath);
115
+ if (seenKeys.has(comparisonKey)) return false;
116
+ seenKeys.add(comparisonKey);
117
+ return true;
118
+ });
119
+ }
120
+
121
+ export function mergeDeclaredProfileSettings(settings, declaredSettings, homeDirectory) {
122
+ const allAdditions = [];
123
+ for (const { keyPath, items } of declaredSettings) {
124
+ const targetList = listAtKeyPath(settings, keyPath);
125
+ for (const eachItem of items) {
126
+ if (listHoldsItem(targetList, eachItem, homeDirectory)) continue;
127
+ const addedItem = itemWithExpandedHookCommands(eachItem, homeDirectory);
128
+ const stopGroup = keyPath.join('.') === 'hooks.Stop'
129
+ ? targetList.find((eachGroup) => (
130
+ eachGroup?.matcher === addedItem.matcher && Array.isArray(eachGroup?.hooks)
131
+ ))
132
+ : null;
133
+ if (stopGroup) {
134
+ const managedGateIndex = stopGroup.hooks.findIndex(
135
+ (eachHook) => typeof eachHook?.command === 'string'
136
+ && eachHook.command.replace(/\\/g, '/').includes('/hooks/blocking/session_title_stop_gate.py'),
137
+ );
138
+ stopGroup.hooks.splice(
139
+ managedGateIndex < 0 ? stopGroup.hooks.length : managedGateIndex,
140
+ 0,
141
+ ...addedItem.hooks,
142
+ );
143
+ } else {
144
+ targetList.push(addedItem);
145
+ }
146
+ allAdditions.push(`${keyPath.join('.')}: ${JSON.stringify(addedItem)}`);
147
+ }
148
+ }
149
+ return allAdditions;
150
+ }
151
+
152
+ /**
153
+ * Merge the declared entries into one settings.json.
154
+ *
155
+ * @param {string} settingsPath
156
+ * @param {{ dryRun: boolean, homeDirectory: string, timestamp: string,
157
+ * declaredSettings?: readonly DeclaredListEntries[] }} options
158
+ * @returns {{ settingsPath: string, additions: string[], backupPath: string | null }}
159
+ */
160
+ export function mergeProfileSettingsFile(settingsPath, options) {
161
+ const settingsText = existsSync(settingsPath) ? readFileSync(settingsPath, 'utf8') : '';
162
+ const settings = settingsText.trim() ? JSON.parse(settingsText) : {};
163
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
164
+ throw new Error(`${settingsPath} holds a value other than a JSON object`);
165
+ }
166
+ const additions = mergeDeclaredProfileSettings(
167
+ settings,
168
+ options.declaredSettings ?? DECLARED_PROFILE_SETTINGS,
169
+ options.homeDirectory,
170
+ );
171
+ if (additions.length === 0 || options.dryRun) {
172
+ return { settingsPath, additions, backupPath: null };
173
+ }
174
+ let backupPath = null;
175
+ if (settingsText) {
176
+ backupPath = `${settingsPath}.${options.timestamp}.bak`;
177
+ copyFileSync(settingsPath, backupPath);
178
+ }
179
+ mkdirSync(dirname(settingsPath), { recursive: true });
180
+ writeFileSync(
181
+ settingsPath,
182
+ JSON.stringify(settings, null, hostConfigurationIndent(settingsText, DEFAULT_SETTINGS_INDENT)) + '\n',
183
+ );
184
+ return { settingsPath, additions, backupPath };
185
+ }
186
+
187
+ /**
188
+ * Copy the session-title gate and its constants module to `~/.claude` when a copy is absent or differs.
189
+ *
190
+ * The Stop hook entry runs the gate, so both files have to land before the entry does.
191
+ *
192
+ * @param {{ homeDirectory: string, dryRun: boolean }} options
193
+ * @returns {{ gatePath: string, changed: boolean }}
194
+ */
195
+ export function installSessionTitleGate(options) {
196
+ const claudeDirectory = join(options.homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME);
197
+ const allStaleRelativePaths = ALL_SESSION_TITLE_GATE_RELATIVE_PATHS.filter((eachRelativePath) => {
198
+ const targetPath = join(claudeDirectory, eachRelativePath);
199
+ const sourceText = readFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), 'utf8');
200
+ return !existsSync(targetPath) || readFileSync(targetPath, 'utf8') !== sourceText;
201
+ });
202
+ if (!options.dryRun) {
203
+ for (const eachRelativePath of allStaleRelativePaths) {
204
+ const targetPath = join(claudeDirectory, eachRelativePath);
205
+ mkdirSync(dirname(targetPath), { recursive: true });
206
+ copyFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), targetPath);
207
+ }
208
+ }
209
+ return {
210
+ gatePath: join(claudeDirectory, SESSION_TITLE_GATE_FILE_NAME),
211
+ changed: allStaleRelativePaths.length > 0,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Install the gate, then merge the declared entries into each settings.json.
217
+ *
218
+ * @param {string[]} allSettingsPaths
219
+ * @param {{ dryRun: boolean, homeDirectory: string, now?: Date }} options
220
+ * @returns {{ gate: { gatePath: string, changed: boolean },
221
+ * files: { settingsPath: string, additions: string[], backupPath: string | null }[] }}
222
+ */
223
+ export function mergeProfileSettings(allSettingsPaths, options) {
224
+ const timestamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
225
+ const gate = installSessionTitleGate(options);
226
+ const files = allSettingsPaths.map((eachPath) => mergeProfileSettingsFile(eachPath, {
227
+ dryRun: options.dryRun,
228
+ homeDirectory: options.homeDirectory,
229
+ timestamp,
230
+ }));
231
+ return { gate, files };
232
+ }
233
+
234
+ /**
235
+ * @param {{ gate: { gatePath: string, changed: boolean },
236
+ * files: { settingsPath: string, additions: string[], backupPath: string | null }[] }} outcome
237
+ * @param {boolean} isDryRun
238
+ * @returns {string[]}
239
+ */
240
+ export function describeProfileSettingsOutcome(outcome, isDryRun) {
241
+ const verb = isDryRun ? 'would add' : 'added';
242
+ const allLines = [
243
+ outcome.gate.changed
244
+ ? `${outcome.gate.gatePath}: ${isDryRun ? 'would copy' : 'copied'} session-title gate`
245
+ : `${outcome.gate.gatePath}: gate current`,
246
+ ];
247
+ for (const { settingsPath, additions, backupPath } of outcome.files) {
248
+ if (additions.length === 0) {
249
+ allLines.push(`${settingsPath}: no change`);
250
+ continue;
251
+ }
252
+ allLines.push(`${settingsPath}: ${verb} ${additions.length}${backupPath ? ` (backup ${backupPath})` : ''}`);
253
+ allLines.push(...additions.map((eachAddition) => ` ${eachAddition}`));
254
+ }
255
+ return allLines;
256
+ }
257
+
258
+ /**
259
+ * @param {Record<string, unknown>} settings
260
+ * @param {string[]} keyPath
261
+ * @returns {unknown[]}
262
+ */
263
+ function listAtKeyPath(settings, keyPath) {
264
+ let container = settings;
265
+ for (const eachKey of keyPath.slice(0, -1)) {
266
+ const child = container[eachKey];
267
+ if (!child || typeof child !== 'object' || Array.isArray(child)) {
268
+ container[eachKey] = {};
269
+ }
270
+ container = /** @type {Record<string, unknown>} */ (container[eachKey]);
271
+ }
272
+ const listKey = keyPath[keyPath.length - 1];
273
+ if (!Array.isArray(container[listKey])) {
274
+ container[listKey] = [];
275
+ }
276
+ return /** @type {unknown[]} */ (container[listKey]);
277
+ }
278
+
279
+ /**
280
+ * @param {unknown[]} targetList
281
+ * @param {unknown} item
282
+ * @param {string} homeDirectory
283
+ * @returns {boolean}
284
+ */
285
+ function listHoldsItem(targetList, item, homeDirectory) {
286
+ if (typeof item === 'string') {
287
+ return targetList.includes(item);
288
+ }
289
+ const presentCommands = new Set(
290
+ targetList.flatMap((eachGroup) => groupCommands(eachGroup, homeDirectory)),
291
+ );
292
+ return groupCommands(item, homeDirectory).every((eachCommand) => presentCommands.has(eachCommand));
293
+ }
294
+
295
+ /**
296
+ * Copy one declared item, spelling each hook command the way the installer writes it.
297
+ *
298
+ * The installer rewrites every hook command's home tokens to the absolute home
299
+ * path on each run. Writing that same spelling here keeps a second install from
300
+ * rewriting the entry this merge added.
301
+ *
302
+ * @param {unknown} item
303
+ * @param {string} homeDirectory
304
+ * @returns {unknown}
305
+ */
306
+ function itemWithExpandedHookCommands(item, homeDirectory) {
307
+ const addedItem = structuredClone(item);
308
+ const allHooks = Array.isArray(addedItem?.hooks) ? addedItem.hooks : [];
309
+ for (const eachHook of allHooks) {
310
+ if (typeof eachHook?.command === 'string') {
311
+ eachHook.command = expandHomeDirectoryTokens(eachHook.command, homeDirectory);
312
+ }
313
+ }
314
+ return addedItem;
315
+ }
316
+
317
+ /**
318
+ * @param {unknown} group
319
+ * @param {string} homeDirectory
320
+ * @returns {string[]}
321
+ */
322
+ function groupCommands(group, homeDirectory) {
323
+ const allHooks = Array.isArray(group?.hooks) ? group.hooks : [];
324
+ return allHooks
325
+ .map((eachHook) => eachHook?.command)
326
+ .filter((eachCommand) => typeof eachCommand === 'string')
327
+ .map((eachCommand) => hookCommandComparisonKey(eachCommand, homeDirectory));
328
+ }
329
+
330
+ /**
331
+ * Spell a hook command the way both the installer and this merge can compare it.
332
+ *
333
+ * Home tokens expand to the one home directory, backslashes become forward
334
+ * slashes, and on Windows case folds, the same rule normalizePathForComparison
335
+ * applies to a path.
336
+ *
337
+ * @param {string} command
338
+ * @param {string} homeDirectory
339
+ * @returns {string}
340
+ */
341
+ function hookCommandComparisonKey(command, homeDirectory) {
342
+ const forwardSlashed = expandHomeDirectoryTokens(command, homeDirectory).replace(/\\/g, '/');
343
+ return process.platform === 'win32' ? forwardSlashed.toLowerCase() : forwardSlashed;
344
+ }
345
+
346
+ /**
347
+ * Read the indent the host configuration file already uses.
348
+ *
349
+ * ~/.claude/settings.json uses four spaces and ~/.codex/hooks.json uses two.
350
+ * Forcing four spaces would rewrite every line of one of those files. The first
351
+ * indented line supplies the indent, tabs included.
352
+ *
353
+ * @param {string} settingsText The host configuration file as it stands on disk.
354
+ * @param {string} [defaultIndent] The indent for a file with no indented line.
355
+ * @returns {string} The indent one nesting level uses.
356
+ */
357
+ export function hostConfigurationIndent(settingsText, defaultIndent = DEFAULT_HOST_CONFIGURATION_INDENT) {
358
+ const firstIndentedLine = /\n([ \t]+)\S/.exec(settingsText);
359
+ return firstIndentedLine ? firstIndentedLine[1] : defaultIndent;
360
+ }
361
+
362
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
363
+ const isDryRun = process.argv.slice(2).includes(DRY_RUN_FLAG);
364
+ const homeDirectory = homedir();
365
+ const outcome = mergeProfileSettings(
366
+ discoverProfileSettingsPaths({ homeDirectory, environment: process.env }),
367
+ { dryRun: isDryRun, homeDirectory },
368
+ );
369
+ for (const eachLine of describeProfileSettingsOutcome(outcome, isDryRun)) {
370
+ console.log(eachLine);
371
+ }
372
+ }