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.
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +87 -9
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +4 -1
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +94 -4
- package/bin/install.agents-home.test.mjs +2 -0
- package/bin/install.codex-rules.test.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +19 -20
- package/bin/install.profile-settings.test.mjs +77 -0
- package/bin/install.profiles.test.mjs +2 -2
- package/bin/install.prune.test.mjs +2 -0
- package/bin/install.question-presentation.test.mjs +2 -0
- package/bin/install.settings-defaults.test.mjs +8 -3
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +7 -3
- package/bin/install.transaction.test.mjs +14 -0
- package/bin/install.uninstall-transaction.test.mjs +4 -0
- package/bin/install.update-keeps-hooks.test.mjs +2 -0
- package/bin/merge_profile_settings.mjs +372 -0
- package/bin/merge_profile_settings.test.mjs +208 -0
- package/installable-surfaces.manifest.json +2 -1
- package/package.json +3 -2
- package/profile-settings/config/__init__.py +1 -0
- package/profile-settings/config/session_title_gate_constants.py +15 -0
- package/profile-settings/session_title_status_gate.py +77 -0
- package/profile-settings/test_session_title_status_gate.py +101 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +8 -4
- package/scripts/private_term_scan.py +15 -3
- package/scripts/tests/test_private_term_scan.py +81 -8
package/bin/install.test.mjs
CHANGED
|
@@ -406,7 +406,9 @@ test('mergeHooksIntoSettings expands residual $HOME in preserved user hooks', ()
|
|
|
406
406
|
],
|
|
407
407
|
},
|
|
408
408
|
};
|
|
409
|
-
mergeHooksIntoSettings(
|
|
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
|
+
}
|