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.
- 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
|
@@ -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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "8.
|
|
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
|
+
)
|