claude-dev-env 8.54.9 → 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.
@@ -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
+ }
@@ -0,0 +1,208 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ import {
8
+ ALL_SESSION_TITLE_GATE_RELATIVE_PATHS,
9
+ AUTO_MODE_DEFAULTS_ENTRY,
10
+ DECLARED_PROFILE_SETTINGS,
11
+ SESSION_TITLE_GATE_FILE_NAME,
12
+ SESSION_TITLE_GATE_SOURCE_DIRECTORY,
13
+ discoverProfileSettingsPaths,
14
+ mergeProfileSettings,
15
+ } from './merge_profile_settings.mjs';
16
+
17
+ const FIXED_NOW = new Date('2026-10-04T12:00:00.000Z');
18
+ const BACKUP_SUFFIX = '.2026-10-04T12-00-00-000Z.bak';
19
+ const PERMISSION_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
20
+ .find((eachEntry) => eachEntry.keyPath.join('.') === 'permissions.allow').items;
21
+ const AUTO_MODE_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
22
+ .find((eachEntry) => eachEntry.keyPath.join('.') === 'autoMode.allow').items;
23
+
24
+ function makeHome() {
25
+ return mkdtempSync(join(tmpdir(), 'cde-profile-settings-'));
26
+ }
27
+
28
+ function writeSettings(settingsPath, settings) {
29
+ mkdirSync(join(settingsPath, '..'), { recursive: true });
30
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
31
+ }
32
+
33
+ function readSettings(settingsPath) {
34
+ return JSON.parse(readFileSync(settingsPath, 'utf8'));
35
+ }
36
+
37
+ function backupFiles(directory) {
38
+ return readdirSync(directory).filter((eachName) => eachName.endsWith('.bak'));
39
+ }
40
+
41
+ function expandedGateCommand(homeDirectory) {
42
+ return `python3 ${homeDirectory.replace(/\\/g, '/')}/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
43
+ }
44
+
45
+ function stopCommands(settings) {
46
+ return settings.hooks.Stop.flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
47
+ }
48
+
49
+ test('a missing settings.json is created with every declared entry and the gate is copied', () => {
50
+ const homeDirectory = makeHome();
51
+ const claudeDirectory = join(homeDirectory, '.claude');
52
+ mkdirSync(claudeDirectory);
53
+ const settingsPath = join(claudeDirectory, 'settings.json');
54
+
55
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
56
+
57
+ assert.deepEqual(readSettings(settingsPath), {
58
+ hooks: {
59
+ Stop: [{ hooks: [{ type: 'command', command: expandedGateCommand(homeDirectory), timeout: 10 }] }],
60
+ },
61
+ permissions: { allow: PERMISSION_ALLOW_ITEMS },
62
+ autoMode: { allow: AUTO_MODE_ALLOW_ITEMS },
63
+ });
64
+ assert.equal(outcome.files[0].backupPath, null);
65
+ assert.deepEqual(backupFiles(claudeDirectory), []);
66
+ for (const eachRelativePath of ALL_SESSION_TITLE_GATE_RELATIVE_PATHS) {
67
+ assert.equal(
68
+ readFileSync(join(claudeDirectory, eachRelativePath), 'utf8'),
69
+ readFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), 'utf8'),
70
+ );
71
+ }
72
+ });
73
+
74
+ test('a gate whose constants module is missing is reported changed and the module is copied', () => {
75
+ const homeDirectory = makeHome();
76
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
77
+ mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
78
+ const constantsPath = join(homeDirectory, '.claude', 'config', 'session_title_gate_constants.py');
79
+ unlinkSync(constantsPath);
80
+
81
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
82
+
83
+ assert.equal(outcome.gate.changed, true);
84
+ assert.equal(existsSync(constantsPath), true);
85
+ });
86
+
87
+ test('a file that already holds every entry is left byte for byte, with no backup', () => {
88
+ const homeDirectory = makeHome();
89
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
90
+ mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
91
+ const textAfterFirstRun = readFileSync(settingsPath, 'utf8');
92
+
93
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
94
+
95
+ assert.deepEqual(outcome.files[0].additions, []);
96
+ assert.equal(outcome.gate.changed, false);
97
+ assert.equal(readFileSync(settingsPath, 'utf8'), textAfterFirstRun);
98
+ assert.deepEqual(backupFiles(join(homeDirectory, '.claude')), []);
99
+ });
100
+
101
+ test('a partial file gains only the missing entries, keeps other keys, and is backed up first', () => {
102
+ const homeDirectory = makeHome();
103
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
104
+ const originalSettings = {
105
+ model: 'opus',
106
+ hooks: {
107
+ Stop: [{ hooks: [{ type: 'command', command: expandedGateCommand(homeDirectory), timeout: 30 }] }],
108
+ PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'python3 guard.py' }] }],
109
+ },
110
+ permissions: { allow: ['Read', 'mcp__claude-code-remote__set_session_title'], deny: ['Bash(rm:*)'] },
111
+ autoMode: { allow: ['Pushing to my fork is allowed'], environment: ['Source control: example'] },
112
+ };
113
+ writeSettings(settingsPath, originalSettings);
114
+ const originalText = readFileSync(settingsPath, 'utf8');
115
+
116
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
117
+
118
+ const mergedSettings = readSettings(settingsPath);
119
+ assert.equal(mergedSettings.model, 'opus');
120
+ assert.deepEqual(mergedSettings.hooks.Stop, originalSettings.hooks.Stop);
121
+ assert.deepEqual(mergedSettings.hooks.PreToolUse, originalSettings.hooks.PreToolUse);
122
+ assert.deepEqual(mergedSettings.permissions.deny, ['Bash(rm:*)']);
123
+ assert.deepEqual(mergedSettings.permissions.allow, [
124
+ 'Read',
125
+ 'mcp__claude-code-remote__set_session_title',
126
+ ...PERMISSION_ALLOW_ITEMS.filter((eachItem) => eachItem !== 'mcp__claude-code-remote__set_session_title'),
127
+ ]);
128
+ assert.deepEqual(mergedSettings.autoMode.allow, ['Pushing to my fork is allowed', ...AUTO_MODE_ALLOW_ITEMS]);
129
+ assert.ok(mergedSettings.autoMode.allow.includes(AUTO_MODE_DEFAULTS_ENTRY));
130
+ assert.deepEqual(mergedSettings.autoMode.environment, ['Source control: example']);
131
+ assert.equal(outcome.files[0].backupPath, `${settingsPath}${BACKUP_SUFFIX}`);
132
+ assert.equal(readFileSync(outcome.files[0].backupPath, 'utf8'), originalText);
133
+ });
134
+
135
+ test('a gate hook written with backslashes counts as present', () => {
136
+ const homeDirectory = makeHome();
137
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
138
+ const backslashedGateCommand = `python3 ${homeDirectory.replace(/\//g, '\\')}\\.claude\\${SESSION_TITLE_GATE_FILE_NAME}`;
139
+ writeSettings(settingsPath, {
140
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: backslashedGateCommand, timeout: 10 }] }] },
141
+ });
142
+
143
+ mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
144
+
145
+ assert.deepEqual(stopCommands(readSettings(settingsPath)), [backslashedGateCommand]);
146
+ });
147
+
148
+ test('the gate hook is added beside a different Stop hook', () => {
149
+ const homeDirectory = makeHome();
150
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
151
+ writeSettings(settingsPath, {
152
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 other_stop.py' }] }] },
153
+ });
154
+
155
+ mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
156
+
157
+ assert.deepEqual(stopCommands(readSettings(settingsPath)), ['python3 other_stop.py', expandedGateCommand(homeDirectory)]);
158
+ });
159
+
160
+ test('discovery finds the main, CLAUDE_CONFIG_DIR, and every profile settings.json once, and each is merged', () => {
161
+ const homeDirectory = makeHome();
162
+ const profilesRoot = join(homeDirectory, '.claude-profiles');
163
+ mkdirSync(join(homeDirectory, '.claude'));
164
+ writeSettings(join(profilesRoot, 'work', 'settings.json'), { model: 'work' });
165
+ writeSettings(join(profilesRoot, 'home', 'settings.json'), { model: 'home' });
166
+ mkdirSync(join(profilesRoot, 'work.agents'));
167
+ const configDirectory = join(homeDirectory, 'custom-config');
168
+ mkdirSync(configDirectory);
169
+
170
+ const allSettingsPaths = discoverProfileSettingsPaths({
171
+ homeDirectory,
172
+ environment: { CLAUDE_CONFIG_DIR: configDirectory },
173
+ });
174
+ const allSettingsPathsWithSharedConfig = discoverProfileSettingsPaths({
175
+ homeDirectory,
176
+ environment: { CLAUDE_CONFIG_DIR: join(profilesRoot, 'work') },
177
+ });
178
+ mergeProfileSettings(allSettingsPaths, { dryRun: false, homeDirectory, now: FIXED_NOW });
179
+
180
+ assert.deepEqual([...allSettingsPaths].sort(), [
181
+ resolve(homeDirectory, '.claude', 'settings.json'),
182
+ resolve(profilesRoot, 'home', 'settings.json'),
183
+ resolve(profilesRoot, 'work', 'settings.json'),
184
+ resolve(configDirectory, 'settings.json'),
185
+ ].sort());
186
+ assert.equal(allSettingsPathsWithSharedConfig.length, 3);
187
+ for (const eachPath of allSettingsPaths) {
188
+ assert.deepEqual(stopCommands(readSettings(eachPath)), [expandedGateCommand(homeDirectory)]);
189
+ assert.deepEqual(readSettings(eachPath).autoMode.allow, AUTO_MODE_ALLOW_ITEMS);
190
+ }
191
+ assert.equal(readSettings(join(profilesRoot, 'work', 'settings.json')).model, 'work');
192
+ assert.equal(backupFiles(join(profilesRoot, 'home')).length, 1);
193
+ });
194
+
195
+ test('a dry run reports additions and writes nothing', () => {
196
+ const homeDirectory = makeHome();
197
+ const settingsPath = join(homeDirectory, '.claude', 'settings.json');
198
+ writeSettings(settingsPath, { model: 'opus' });
199
+ const originalText = readFileSync(settingsPath, 'utf8');
200
+
201
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: true, homeDirectory, now: FIXED_NOW });
202
+
203
+ assert.equal(outcome.files[0].additions.length, 7);
204
+ assert.equal(outcome.gate.changed, true);
205
+ assert.equal(readFileSync(settingsPath, 'utf8'), originalText);
206
+ assert.deepEqual(backupFiles(join(homeDirectory, '.claude')), []);
207
+ assert.equal(existsSync(join(homeDirectory, '.claude', SESSION_TITLE_GATE_FILE_NAME)), false);
208
+ });
@@ -10,7 +10,8 @@
10
10
  "system-prompts",
11
11
  "scripts",
12
12
  "_shared",
13
- "audit-rubrics"
13
+ "audit-rubrics",
14
+ "profile-settings"
14
15
  ],
15
16
  "root_files": [
16
17
  "AGENTS.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.54.9",
3
+ "version": "8.55.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "cde": "bin/cde.mjs"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\" \"scripts/ci/*.test.mjs\""
12
+ "test": "node --test --test-concurrency=1 \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\" \"scripts/ci/*.test.mjs\""
13
13
  },
14
14
  "files": [
15
15
  "bin/",
@@ -25,6 +25,7 @@
25
25
  "settings.json",
26
26
  "installable-surfaces.manifest.json",
27
27
  "_shared/",
28
+ "profile-settings/",
28
29
  "audit-rubrics/",
29
30
  "codex-rules/",
30
31
  "pstack-codex-models.md",
@@ -0,0 +1 @@
1
+ """Constants package installed beside the Stop hook gate."""
@@ -0,0 +1,15 @@
1
+ """Constants for the session-title Stop hook gate."""
2
+
3
+ ALL_TITLE_TOOL_NAMES = frozenset(
4
+ {
5
+ "mcp__claude-code-remote__set_session_title",
6
+ "mcp__ccd_session_mgmt__set_session_title",
7
+ }
8
+ )
9
+ INSTRUCTION = (
10
+ "Call set_session_title (session id from get_session) before you stop. "
11
+ 'Title = "<emoji> <brief task name>", one line in a narrow sidebar. '
12
+ "\U0001f6a9 = Jon must act (question, approval, a step only he can do). "
13
+ "✅ = fully done (merged or nothing left). "
14
+ "⏳ = work, CI, or a merge queue still running and nothing waits on Jon."
15
+ )