claude-dev-env 7.6.0 → 7.7.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/.agents/skills/session-continuity/README.md +19 -9
- package/AGENTS.md +48 -71
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/advisor-block.md +1 -1
- package/_shared/advisor/reference/astra-rung.md +2 -2
- package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
- package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
- package/bin/install-constants.mjs +3 -0
- package/bin/install-session-continuity.mjs +73 -30
- package/bin/install-session-continuity.test.mjs +52 -1
- package/bin/install-transaction.mjs +41 -44
- package/bin/install.cursor-rules.test.mjs +311 -3
- package/bin/install.mjs +226 -95
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +61 -1
- package/bin/pstack-model-preferences.codex.json +1 -0
- package/bin/pstack.mjs +28 -6
- package/bin/pstack.test.mjs +48 -0
- package/bin/select-pstack-models.test.mjs +375 -26
- package/bin/subagent-model-policy.test.mjs +399 -0
- package/docs/codex-compatibility.md +19 -5
- package/docs/subagent-model-routing.md +50 -0
- package/hooks/hooks.json +10 -0
- package/hooks/routing/subagent_model_routing.mjs +81 -0
- package/hooks/routing/subagent_model_routing.test.mjs +218 -0
- package/package.json +1 -1
- package/rules/pstack-models.md +22 -0
- package/rules/subagent-model-policy.json +85 -0
- package/scripts/resolve_advisor_model_route.mjs +52 -0
- package/scripts/select_pstack_models.mjs +203 -38
- package/scripts/subagent_model_policy.mjs +620 -0
- package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
- package/scripts/tests/test_codex_compat_materializer.py +15 -0
package/bin/install.mjs
CHANGED
|
@@ -30,13 +30,17 @@ import {
|
|
|
30
30
|
MANAGED_HOOKS_DIRECTORY_NAME,
|
|
31
31
|
MANAGED_SCRIPTS_DIRECTORY_NAME,
|
|
32
32
|
SETTINGS_FILE_NAME,
|
|
33
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
34
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
33
35
|
CODEX_RULES_PACKAGE_DIRECTORY_NAME,
|
|
34
36
|
CURSOR_SYNC_SCRIPT_FILE_NAME,
|
|
35
37
|
CURSOR_RULES_DIRECTORY_NAME,
|
|
36
38
|
PSTACK_MODEL_RULE_FILE_NAME,
|
|
39
|
+
SUBAGENT_MODEL_POLICY_FILE_NAME,
|
|
37
40
|
PSTACK_SEEDED_MODEL_PREFERENCES_FILE_NAMES,
|
|
38
41
|
WINDOWS_PYTHON_LAUNCHER_COMMAND,
|
|
39
42
|
PYTHON_PROBE_TIMEOUT_MILLISECONDS,
|
|
43
|
+
CODEX_NATIVE_ROUTING_MATCHER,
|
|
40
44
|
} from './install-constants.mjs';
|
|
41
45
|
import {
|
|
42
46
|
PSTACK_PLUGIN_DIRECTORY_NAME,
|
|
@@ -44,6 +48,11 @@ import {
|
|
|
44
48
|
refreshPstackPluginManifest,
|
|
45
49
|
} from '../scripts/refresh_pstack_plugin_skills.mjs';
|
|
46
50
|
import { installPstack } from './pstack.mjs';
|
|
51
|
+
import {
|
|
52
|
+
configureContinuityHosts,
|
|
53
|
+
continuityHostConfigurationPaths,
|
|
54
|
+
removeContinuityHooks,
|
|
55
|
+
} from './install-session-continuity.mjs';
|
|
47
56
|
import {
|
|
48
57
|
resolveInstallRoot,
|
|
49
58
|
parseExplicitTargetFromArgv,
|
|
@@ -509,6 +518,77 @@ function seedPstackPreferences(preferenceFileName) {
|
|
|
509
518
|
}
|
|
510
519
|
}
|
|
511
520
|
|
|
521
|
+
function seedSubagentModelPolicy() {
|
|
522
|
+
const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
|
|
523
|
+
const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
|
|
524
|
+
const policySource = join(PACKAGE_ROOT, 'rules', SUBAGENT_MODEL_POLICY_FILE_NAME);
|
|
525
|
+
mkdirSync(policyDirectory, { recursive: true });
|
|
526
|
+
try {
|
|
527
|
+
copyFileSync(policySource, policyPath, filesystemConstants.COPYFILE_EXCL);
|
|
528
|
+
return policyPath;
|
|
529
|
+
} catch (copyError) {
|
|
530
|
+
if (copyError.code === 'EEXIST') return null;
|
|
531
|
+
throw copyError;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Registers the session-continuity companion in each host configuration this
|
|
537
|
+
* install root resolves. Without this step the companion ships to the agents
|
|
538
|
+
* home but no host ever calls it, so Poteto Mode only activates when someone
|
|
539
|
+
* remembers a second setup command.
|
|
540
|
+
*
|
|
541
|
+
* A competing profile's registration, or an unwritable host configuration, is
|
|
542
|
+
* reported and leaves the rest of the install intact.
|
|
543
|
+
*
|
|
544
|
+
* @returns {string[]} The host configuration paths this run registered.
|
|
545
|
+
*/
|
|
546
|
+
function registerSessionContinuityHooks() {
|
|
547
|
+
const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
|
|
548
|
+
const hosts = Object.keys(configurationPaths)
|
|
549
|
+
.filter(host => existsSync(dirname(configurationPaths[host])));
|
|
550
|
+
if (hosts.length === 0) return [];
|
|
551
|
+
try {
|
|
552
|
+
const results = configureContinuityHosts(INSTALL_ROOT_RESOLUTION, hosts);
|
|
553
|
+
for (const result of results) {
|
|
554
|
+
const state = result.changed ? 'registered' : 'already registered';
|
|
555
|
+
console.log(` Session continuity: ${state} in ${result.path}`);
|
|
556
|
+
}
|
|
557
|
+
return results.map(result => result.path);
|
|
558
|
+
} catch (registrationError) {
|
|
559
|
+
console.warn(
|
|
560
|
+
` Warning: session-continuity hooks were not registered (${registrationError.message}) — `
|
|
561
|
+
+ 'run bin/install-session-continuity.mjs once the cause is resolved.',
|
|
562
|
+
);
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Removes the companion's registrations from the Codex and Cursor hook files
|
|
569
|
+
* during an uninstall. The Claude settings.json copy is pruned inside the
|
|
570
|
+
* settings block, which owns that file's single write.
|
|
571
|
+
*
|
|
572
|
+
* Each host this installer registered is a host it has to clean, or the host
|
|
573
|
+
* keeps calling a script the uninstall deleted.
|
|
574
|
+
*
|
|
575
|
+
* @returns {string[]} The host configuration paths this run changed.
|
|
576
|
+
*/
|
|
577
|
+
function removeSessionContinuityHooksFromOtherHosts() {
|
|
578
|
+
const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
|
|
579
|
+
const changedPaths = [];
|
|
580
|
+
for (const host of ['codex', 'cursor']) {
|
|
581
|
+
const configurationPath = configurationPaths[host];
|
|
582
|
+
if (!existsSync(configurationPath)) continue;
|
|
583
|
+
const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
|
|
584
|
+
if (removeContinuityHooks(configuration) === 0) continue;
|
|
585
|
+
writeFileSync(configurationPath, JSON.stringify(configuration, null, 2) + '\n');
|
|
586
|
+
changedPaths.push(configurationPath);
|
|
587
|
+
console.log(` Session continuity: hook registrations removed from ${configurationPath}`);
|
|
588
|
+
}
|
|
589
|
+
return changedPaths;
|
|
590
|
+
}
|
|
591
|
+
|
|
512
592
|
/**
|
|
513
593
|
* Formats an absolute interpreter path as a settings.json hook command prefix:
|
|
514
594
|
* forward-slash separators, double-quoted when the path contains a space so the
|
|
@@ -1153,22 +1233,11 @@ function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirec
|
|
|
1153
1233
|
existingEntryNames[existingEntryNames.indexOf(caseOnlyMatchName)] = shippedFileName;
|
|
1154
1234
|
}
|
|
1155
1235
|
|
|
1156
|
-
/**
|
|
1157
|
-
* Copy every file under a source directory into a destination directory,
|
|
1158
|
-
* reporting what the run created and what it updated.
|
|
1159
|
-
*
|
|
1160
|
-
* A destination entry whose name differs from the shipped name only in letter
|
|
1161
|
-
* case is renamed to the shipped name before the copy, so the tree carries the
|
|
1162
|
-
* spelling the package ships. The directory listing behind that decision is read
|
|
1163
|
-
* once per destination directory and reused for every file the run copies there.
|
|
1164
|
-
*
|
|
1165
|
-
* @param {string} sourceBase The absolute source directory to copy from.
|
|
1166
|
-
* @param {string} destBase The absolute destination directory to copy into.
|
|
1167
|
-
* @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
|
|
1168
|
-
* @returns {{created: number, updated: number, paths: string[]}} The counts and the destination paths written.
|
|
1169
|
-
*/
|
|
1170
1236
|
export function copyTree(sourceBase, destBase, options = {}) {
|
|
1171
|
-
const
|
|
1237
|
+
const excludedFileNames = new Set(options.excludeFileNames ?? []);
|
|
1238
|
+
const files = collectFiles(sourceBase).filter(
|
|
1239
|
+
sourceFile => !excludedFileNames.has(basename(sourceFile)),
|
|
1240
|
+
);
|
|
1172
1241
|
const stats = { created: 0, updated: 0, paths: [] };
|
|
1173
1242
|
const entryNamesByDirectory = new Map();
|
|
1174
1243
|
for (const sourceFile of files) {
|
|
@@ -1212,15 +1281,6 @@ function backupHubBeforeOverwrite(destPath, incomingPath, backupName) {
|
|
|
1212
1281
|
return backupPath;
|
|
1213
1282
|
}
|
|
1214
1283
|
|
|
1215
|
-
/**
|
|
1216
|
-
* Hook script paths the installer manages even though hooks.json
|
|
1217
|
-
* carries no standalone entry for them. Most were folded into the PreToolUse
|
|
1218
|
-
* dispatcher in Stage 1; retired hooks have no current registration.
|
|
1219
|
-
* Each path stays in this set so a reinstall from an older settings shape
|
|
1220
|
-
* prunes its standalone entry — a folded hook would otherwise double-run
|
|
1221
|
-
* alongside the dispatcher, and a retired hook's entry would continue to run
|
|
1222
|
-
* from the user's settings.json.
|
|
1223
|
-
*/
|
|
1224
1284
|
export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
|
|
1225
1285
|
'blocking/write_existing_file_blocker.py',
|
|
1226
1286
|
'blocking/sensitive_file_protector.py',
|
|
@@ -1296,25 +1356,18 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1296
1356
|
'blocking/session_handoff_blocker.py',
|
|
1297
1357
|
]);
|
|
1298
1358
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
* `python3 -c` commands reference the hooks directory without a script tail and
|
|
1304
|
-
* contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
|
|
1305
|
-
* and the retained path sets so a reinstall from an older settings shape prunes
|
|
1306
|
-
* folded and retired entries.
|
|
1307
|
-
*
|
|
1308
|
-
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
1309
|
-
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
1310
|
-
*/
|
|
1359
|
+
export const MOVED_HOOK_RELATIVE_PATHS = new Set([
|
|
1360
|
+
'blocking/subagent_model_routing.mjs',
|
|
1361
|
+
]);
|
|
1362
|
+
|
|
1311
1363
|
export function managedHookScriptRelativePaths(hooksConfig) {
|
|
1312
1364
|
const relativePaths = new Set([
|
|
1313
1365
|
...FOLDED_HOOK_RELATIVE_PATHS,
|
|
1314
1366
|
...POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
1315
1367
|
...RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
|
|
1368
|
+
...MOVED_HOOK_RELATIVE_PATHS,
|
|
1316
1369
|
]);
|
|
1317
|
-
const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
|
|
1370
|
+
const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g;
|
|
1318
1371
|
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
1319
1372
|
for (const sourceGroup of matcherGroups) {
|
|
1320
1373
|
for (const hook of sourceGroup.hooks) {
|
|
@@ -1395,19 +1448,16 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
|
|
|
1395
1448
|
return false;
|
|
1396
1449
|
}
|
|
1397
1450
|
|
|
1398
|
-
/**
|
|
1399
|
-
* Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
|
|
1400
|
-
* at a path boundary: end of string, or an argument separator (whitespace, quote,
|
|
1401
|
-
* or semicolon). Anchoring the tail keeps a user hook whose path is the managed
|
|
1402
|
-
* tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
|
|
1403
|
-
* the managed set, so it is never pruned.
|
|
1404
|
-
*
|
|
1405
|
-
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
1406
|
-
* @param {string} relativePath Managed script path under hooks/.
|
|
1407
|
-
* @returns {boolean} True when the managed tail ends at a path boundary.
|
|
1408
|
-
*/
|
|
1409
1451
|
function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
|
|
1410
|
-
|
|
1452
|
+
if (commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`)) {
|
|
1453
|
+
return true;
|
|
1454
|
+
}
|
|
1455
|
+
return MANAGED_HOOK_ROOT_PATHS.some(eachHooksRoot => (
|
|
1456
|
+
commandHoldsPathAtBoundary(
|
|
1457
|
+
normalizedCommand,
|
|
1458
|
+
`${eachHooksRoot.replace(/\\/g, '/')}/${relativePath}`,
|
|
1459
|
+
)
|
|
1460
|
+
));
|
|
1411
1461
|
}
|
|
1412
1462
|
|
|
1413
1463
|
/**
|
|
@@ -2098,16 +2148,7 @@ function reportRetiredHookStandIns(allHookRootPaths, standInPaths) {
|
|
|
2098
2148
|
}
|
|
2099
2149
|
|
|
2100
2150
|
|
|
2101
|
-
|
|
2102
|
-
* Load ~/.claude/settings.json for an in-place merge, or `{}` when absent/empty.
|
|
2103
|
-
*
|
|
2104
|
-
* Malformed JSON or a non-object root ends the process so install never writes
|
|
2105
|
-
* onto a shape the harness cannot read.
|
|
2106
|
-
*
|
|
2107
|
-
* @param {string} settingsPath Absolute path to settings.json.
|
|
2108
|
-
* @returns {object}
|
|
2109
|
-
*/
|
|
2110
|
-
function loadClaudeSettingsObjectOrExit(settingsPath) {
|
|
2151
|
+
function loadClaudeSettingsObject(settingsPath) {
|
|
2111
2152
|
if (!existsSync(settingsPath)) {
|
|
2112
2153
|
return {};
|
|
2113
2154
|
}
|
|
@@ -2119,42 +2160,67 @@ function loadClaudeSettingsObjectOrExit(settingsPath) {
|
|
|
2119
2160
|
try {
|
|
2120
2161
|
settings = JSON.parse(raw);
|
|
2121
2162
|
} catch {
|
|
2122
|
-
|
|
2123
|
-
process.exit(1);
|
|
2163
|
+
throw new Error('settings.json is malformed JSON. Fix it and rerun.');
|
|
2124
2164
|
}
|
|
2125
2165
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
2126
|
-
|
|
2127
|
-
process.exit(1);
|
|
2166
|
+
throw new Error('settings.json holds a value other than a JSON object. Fix it and rerun.');
|
|
2128
2167
|
}
|
|
2129
2168
|
return settings;
|
|
2130
2169
|
}
|
|
2131
2170
|
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
2141
|
-
* @returns {number} Count of matcher groups merged.
|
|
2142
|
-
*/
|
|
2143
|
-
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
2171
|
+
function mergeHooksAtPath(
|
|
2172
|
+
hooksSourceRoot,
|
|
2173
|
+
settingsPath,
|
|
2174
|
+
indentation,
|
|
2175
|
+
pythonCommand,
|
|
2176
|
+
selectHooksConfig = hooksConfig => hooksConfig,
|
|
2177
|
+
pluginRootDir = CLAUDE_HOME,
|
|
2178
|
+
) {
|
|
2144
2179
|
const hooksSource = resolvePackageManagedDirectory(
|
|
2145
2180
|
hooksSourceRoot,
|
|
2146
2181
|
MANAGED_HOOKS_DIRECTORY_NAME,
|
|
2147
2182
|
);
|
|
2148
2183
|
const hooksJsonPath = join(hooksSource, 'hooks.json');
|
|
2149
2184
|
if (!existsSync(hooksJsonPath)) return 0;
|
|
2150
|
-
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
2151
|
-
const
|
|
2152
|
-
const
|
|
2153
|
-
|
|
2154
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
2185
|
+
const hooksConfig = selectHooksConfig(JSON.parse(readFileSync(hooksJsonPath, 'utf8')));
|
|
2186
|
+
const settings = loadClaudeSettingsObject(settingsPath);
|
|
2187
|
+
const groupCount = mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand);
|
|
2188
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
|
|
2155
2189
|
return groupCount;
|
|
2156
2190
|
}
|
|
2157
2191
|
|
|
2192
|
+
function selectCodexNativeHooksConfig(hooksConfig) {
|
|
2193
|
+
const preToolUseGroups = hooksConfig.hooks?.PreToolUse;
|
|
2194
|
+
return {
|
|
2195
|
+
...hooksConfig,
|
|
2196
|
+
hooks: {
|
|
2197
|
+
PreToolUse: Array.isArray(preToolUseGroups)
|
|
2198
|
+
? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
|
|
2199
|
+
: [],
|
|
2200
|
+
},
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
2205
|
+
return mergeHooksAtPath(
|
|
2206
|
+
hooksSourceRoot,
|
|
2207
|
+
join(CLAUDE_HOME, SETTINGS_FILE_NAME),
|
|
2208
|
+
4,
|
|
2209
|
+
pythonCommand,
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function mergeCodexHooks(hooksSourceRoot, pythonCommand) {
|
|
2214
|
+
return mergeHooksAtPath(
|
|
2215
|
+
hooksSourceRoot,
|
|
2216
|
+
CODEX_HOOKS_CONFIGURATION_PATH,
|
|
2217
|
+
2,
|
|
2218
|
+
pythonCommand,
|
|
2219
|
+
selectCodexNativeHooksConfig,
|
|
2220
|
+
dirname(CODEX_HOOKS_CONFIGURATION_PATH),
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2158
2224
|
function writeManifest(installedFiles, skillNames, managedPermissions = null) {
|
|
2159
2225
|
const manifest = buildTargetManifestRecord({
|
|
2160
2226
|
packageName: PACKAGE_NAME,
|
|
@@ -2234,7 +2300,7 @@ function mergeManagedPermissions() {
|
|
|
2234
2300
|
}
|
|
2235
2301
|
const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
|
|
2236
2302
|
const settingsExisted = existsSync(settingsPath);
|
|
2237
|
-
const settings =
|
|
2303
|
+
const settings = loadClaudeSettingsObject(settingsPath);
|
|
2238
2304
|
const pruneOutcome = pruneManagedPermissionsFromSettings(settings, allRetiredManagedDenyEntries);
|
|
2239
2305
|
const mergeOutcome = hasCurrentManagedDenyEntries
|
|
2240
2306
|
? mergeManagedPermissionsIntoSettings(settings, allCurrentManagedDenyEntries)
|
|
@@ -2536,6 +2602,7 @@ function executeInstallPlan(plan) {
|
|
|
2536
2602
|
managedRoot: CLAUDE_HOME,
|
|
2537
2603
|
manifestFilePath: MANIFEST_FILE,
|
|
2538
2604
|
settingsPath: plan.settingsPath,
|
|
2605
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
2539
2606
|
priorManifestFiles: plan.priorManifest.files,
|
|
2540
2607
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
2541
2608
|
});
|
|
@@ -2644,7 +2711,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2644
2711
|
if (!existsSync(sourceDir)) continue;
|
|
2645
2712
|
const destDir = join(CLAUDE_HOME, directory);
|
|
2646
2713
|
if (hasFullAccess) {
|
|
2647
|
-
const stats = copyTree(sourceDir, destDir
|
|
2714
|
+
const stats = copyTree(sourceDir, destDir, directory === 'rules'
|
|
2715
|
+
? { excludeFileNames: [SUBAGENT_MODEL_POLICY_FILE_NAME] }
|
|
2716
|
+
: {});
|
|
2648
2717
|
if (!summary[directory]) {
|
|
2649
2718
|
summary[directory] = stats;
|
|
2650
2719
|
} else {
|
|
@@ -2658,7 +2727,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2658
2727
|
let rulesUpdated = 0;
|
|
2659
2728
|
for (const ruleFile of allowedRules) {
|
|
2660
2729
|
const sourcePath = join(sourceDir, ruleFile);
|
|
2661
|
-
if (!existsSync(sourcePath)) continue;
|
|
2730
|
+
if (!existsSync(sourcePath) || ruleFile === SUBAGENT_MODEL_POLICY_FILE_NAME) continue;
|
|
2662
2731
|
const destPath = join(destDir, ruleFile);
|
|
2663
2732
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
2664
2733
|
const existed = existsSync(destPath);
|
|
@@ -2756,6 +2825,11 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2756
2825
|
allUserOwnedPreferencePaths.add(seededPreferencePath);
|
|
2757
2826
|
}
|
|
2758
2827
|
}
|
|
2828
|
+
const seededPolicyPath = seedSubagentModelPolicy();
|
|
2829
|
+
if (seededPolicyPath) {
|
|
2830
|
+
allInstalledFiles.push(seededPolicyPath);
|
|
2831
|
+
allUserOwnedPreferencePaths.add(seededPolicyPath);
|
|
2832
|
+
}
|
|
2759
2833
|
syncWrittenPaths(allInstalledFiles);
|
|
2760
2834
|
}
|
|
2761
2835
|
let skillsCreated = 0;
|
|
@@ -2804,6 +2878,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2804
2878
|
let totalHooksCreated = 0;
|
|
2805
2879
|
let totalHooksUpdated = 0;
|
|
2806
2880
|
let totalHookGroups = 0;
|
|
2881
|
+
let totalCodexHookGroups = 0;
|
|
2807
2882
|
for (const sourceRoot of allSourceRoots) {
|
|
2808
2883
|
const hooksSource = resolvePackageManagedDirectory(
|
|
2809
2884
|
sourceRoot,
|
|
@@ -2829,11 +2904,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2829
2904
|
}
|
|
2830
2905
|
const groupCount = mergeHooks(sourceRoot, pythonCommand);
|
|
2831
2906
|
totalHookGroups += groupCount;
|
|
2907
|
+
totalCodexHookGroups += mergeCodexHooks(sourceRoot, pythonCommand);
|
|
2832
2908
|
}
|
|
2833
2909
|
summary.hookFiles = { created: totalHooksCreated, updated: totalHooksUpdated };
|
|
2834
2910
|
console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
|
|
2835
2911
|
summary.hookGroups = totalHookGroups;
|
|
2836
|
-
|
|
2912
|
+
summary.codexHookGroups = totalCodexHookGroups;
|
|
2913
|
+
console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
|
|
2837
2914
|
syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
|
|
2838
2915
|
throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
|
|
2839
2916
|
|
|
@@ -2922,6 +2999,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2922
2999
|
console.log(` \u2713 ${relative(CLAUDE_HOME, agentsHubDest)} (canonical guidance)`);
|
|
2923
3000
|
}
|
|
2924
3001
|
const isFullInstall = !selectedGroups;
|
|
3002
|
+
if (isFullInstall && shouldInstallAnyHooks) {
|
|
3003
|
+
summary.sessionContinuity = { configuredPaths: registerSessionContinuityHooks() };
|
|
3004
|
+
}
|
|
2925
3005
|
const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
|
|
2926
3006
|
let failedPrunePaths = [];
|
|
2927
3007
|
let stalePrunedTotal = 0;
|
|
@@ -3092,15 +3172,40 @@ function resolveUninstallPlan(isManifestRequired) {
|
|
|
3092
3172
|
});
|
|
3093
3173
|
}
|
|
3094
3174
|
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3175
|
+
function pruneManagedHooksFromHostConfiguration(settingsPath, defaultIndentation) {
|
|
3176
|
+
if (!existsSync(settingsPath)) return false;
|
|
3177
|
+
let settingsText;
|
|
3178
|
+
let settings;
|
|
3179
|
+
try {
|
|
3180
|
+
settingsText = readFileSync(settingsPath, 'utf8');
|
|
3181
|
+
settings = JSON.parse(settingsText);
|
|
3182
|
+
} catch (readError) {
|
|
3183
|
+
throw new Error(`host hook configuration is malformed JSON: ${readError.message}`);
|
|
3184
|
+
}
|
|
3185
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
3186
|
+
throw new Error('host hook configuration must hold a JSON object');
|
|
3187
|
+
}
|
|
3188
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return false;
|
|
3189
|
+
const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
|
|
3190
|
+
managedPackageSourceRoots(),
|
|
3191
|
+
);
|
|
3192
|
+
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
3193
|
+
writeFileSync(
|
|
3194
|
+
settingsPath,
|
|
3195
|
+
JSON.stringify(settings, null, hostConfigurationIndent(settingsText) || defaultIndentation) + '\n',
|
|
3196
|
+
);
|
|
3197
|
+
console.log(` Hook entries removed from ${basename(settingsPath)}`);
|
|
3198
|
+
return true;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
function codexRoutingHookExists() {
|
|
3202
|
+
return existsSync(join(
|
|
3203
|
+
INSTALL_ROOT_RESOLUTION.codexHooksInstallDirectory,
|
|
3204
|
+
'routing',
|
|
3205
|
+
'subagent_model_routing.mjs',
|
|
3206
|
+
));
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3104
3209
|
function executeUninstallPlan(plan, helpers = {}) {
|
|
3105
3210
|
const throwIfFault = helpers.throwIfFault || (() => {});
|
|
3106
3211
|
let removed = 0;
|
|
@@ -3139,6 +3244,10 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3139
3244
|
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
3140
3245
|
didSettingsChange = true;
|
|
3141
3246
|
console.log(' Hook entries removed from settings.json');
|
|
3247
|
+
const removedContinuityCount = removeContinuityHooks(settings);
|
|
3248
|
+
if (removedContinuityCount > 0) {
|
|
3249
|
+
console.log(` Session continuity: ${removedContinuityCount} hook registration(s) removed from settings.json`);
|
|
3250
|
+
}
|
|
3142
3251
|
}
|
|
3143
3252
|
const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
|
|
3144
3253
|
? plan.managedPermissionDenyEntries
|
|
@@ -3156,6 +3265,10 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3156
3265
|
writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
3157
3266
|
}
|
|
3158
3267
|
}
|
|
3268
|
+
if (!codexRoutingHookExists()) {
|
|
3269
|
+
pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
|
|
3270
|
+
}
|
|
3271
|
+
removeSessionContinuityHooksFromOtherHosts();
|
|
3159
3272
|
throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
|
|
3160
3273
|
|
|
3161
3274
|
unsetGlobalGitHooksPathIfOurs();
|
|
@@ -3230,6 +3343,7 @@ function uninstall() {
|
|
|
3230
3343
|
managedRoot: CLAUDE_HOME,
|
|
3231
3344
|
manifestFilePath: MANIFEST_FILE,
|
|
3232
3345
|
settingsPath: plan.settingsPath,
|
|
3346
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
3233
3347
|
priorManifestFiles: plan.removableFiles,
|
|
3234
3348
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
3235
3349
|
});
|
|
@@ -3377,7 +3491,9 @@ function loadDirectoryNameByProfileId() {
|
|
|
3377
3491
|
function runInstallForAllTargets(allTargets, childArgv) {
|
|
3378
3492
|
if (allTargets.length === 1) {
|
|
3379
3493
|
const onlyTarget = allTargets[0];
|
|
3380
|
-
if (onlyTarget.managedRoot !== CLAUDE_HOME
|
|
3494
|
+
if (onlyTarget.managedRoot !== CLAUDE_HOME
|
|
3495
|
+
|| onlyTarget.targetIdentity !== INSTALL_TARGET_IDENTITY
|
|
3496
|
+
|| !isDefaultClaudeTarget(onlyTarget)) {
|
|
3381
3497
|
const childStatus = spawnInstallChild(onlyTarget, childArgv);
|
|
3382
3498
|
process.exit(childStatus);
|
|
3383
3499
|
}
|
|
@@ -3404,6 +3520,13 @@ function runInstallForAllTargets(allTargets, childArgv) {
|
|
|
3404
3520
|
* @returns {number | null}
|
|
3405
3521
|
*/
|
|
3406
3522
|
function spawnInstallChild(target, childArgv) {
|
|
3523
|
+
const childEnvironment = { ...process.env };
|
|
3524
|
+
if (!isDefaultClaudeTarget(target)) {
|
|
3525
|
+
childEnvironment[CODEX_HOME_ENVIRONMENT_VARIABLE] = join(
|
|
3526
|
+
target.managedRoot,
|
|
3527
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
3528
|
+
);
|
|
3529
|
+
}
|
|
3407
3530
|
const childProcess = spawnSync(
|
|
3408
3531
|
process.execPath,
|
|
3409
3532
|
[
|
|
@@ -3416,7 +3539,7 @@ function spawnInstallChild(target, childArgv) {
|
|
|
3416
3539
|
],
|
|
3417
3540
|
{
|
|
3418
3541
|
stdio: 'inherit',
|
|
3419
|
-
env:
|
|
3542
|
+
env: childEnvironment,
|
|
3420
3543
|
},
|
|
3421
3544
|
);
|
|
3422
3545
|
if (childProcess.error) {
|
|
@@ -3426,6 +3549,10 @@ function spawnInstallChild(target, childArgv) {
|
|
|
3426
3549
|
return childProcess.status === null ? 1 : childProcess.status;
|
|
3427
3550
|
}
|
|
3428
3551
|
|
|
3552
|
+
function isDefaultClaudeTarget(target) {
|
|
3553
|
+
return resolve(target.managedRoot) === resolve(join(homedir(), '.claude'));
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3429
3556
|
if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
|
|
3430
3557
|
const rawArgs = process.argv.slice(2);
|
|
3431
3558
|
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
@@ -3471,6 +3598,10 @@ if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
|
|
|
3471
3598
|
|| (
|
|
3472
3599
|
targetSelection.mode === 'profiles'
|
|
3473
3600
|
&& allTargets[0].targetIdentity !== INSTALL_TARGET_IDENTITY
|
|
3601
|
+
)
|
|
3602
|
+
|| (
|
|
3603
|
+
targetSelection.mode !== 'child-identity'
|
|
3604
|
+
&& !isDefaultClaudeTarget(allTargets[0])
|
|
3474
3605
|
);
|
|
3475
3606
|
|
|
3476
3607
|
if (needsChildHop && !args.includes('--uninstall')) {
|
package/bin/install.test.mjs
CHANGED
|
@@ -3088,3 +3088,60 @@ test('--no-pstack leaves the pstack store absent', t => {
|
|
|
3088
3088
|
assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
|
|
3089
3089
|
});
|
|
3090
3090
|
});
|
|
3091
|
+
|
|
3092
|
+
function runContinuityInstaller(homeDirectory, extraArguments) {
|
|
3093
|
+
return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
|
|
3094
|
+
cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
|
|
3095
|
+
encoding: 'utf8',
|
|
3096
|
+
env: {
|
|
3097
|
+
...process.env,
|
|
3098
|
+
CDE_INSTALL_PSTACK: '0',
|
|
3099
|
+
HOME: homeDirectory,
|
|
3100
|
+
USERPROFILE: homeDirectory,
|
|
3101
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
3102
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
3103
|
+
},
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
function continuityCommandCount(configurationPath) {
|
|
3108
|
+
if (!existsSync(configurationPath)) return 0;
|
|
3109
|
+
const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
|
|
3110
|
+
const allCommands = Object.values(configuration.hooks || {})
|
|
3111
|
+
.flat()
|
|
3112
|
+
.flatMap(entry => (entry.hooks ? entry.hooks.map(eachHook => eachHook.command) : [entry.command]));
|
|
3113
|
+
return allCommands.filter(
|
|
3114
|
+
eachCommand => typeof eachCommand === 'string'
|
|
3115
|
+
&& eachCommand.includes('session-continuity/continuity.mjs'),
|
|
3116
|
+
).length;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
test('a full install registers the session-continuity companion in every host, and uninstall takes it back out', t => {
|
|
3120
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-continuity-install-'));
|
|
3121
|
+
t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
3122
|
+
const allConfigurationPaths = [
|
|
3123
|
+
join(homeDirectory, '.claude', 'settings.json'),
|
|
3124
|
+
join(homeDirectory, '.codex', 'hooks.json'),
|
|
3125
|
+
join(homeDirectory, '.cursor', 'hooks.json'),
|
|
3126
|
+
];
|
|
3127
|
+
|
|
3128
|
+
runContinuityInstaller(homeDirectory, []);
|
|
3129
|
+
|
|
3130
|
+
for (const configurationPath of allConfigurationPaths) {
|
|
3131
|
+
assert.equal(
|
|
3132
|
+
continuityCommandCount(configurationPath) > 0,
|
|
3133
|
+
true,
|
|
3134
|
+
`the install registers the companion in ${configurationPath} without a second setup command`,
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
runContinuityInstaller(homeDirectory, ['--uninstall']);
|
|
3139
|
+
|
|
3140
|
+
for (const configurationPath of allConfigurationPaths) {
|
|
3141
|
+
assert.equal(
|
|
3142
|
+
continuityCommandCount(configurationPath),
|
|
3143
|
+
0,
|
|
3144
|
+
`uninstall leaves no registration in ${configurationPath} pointing at a removed script`,
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
@@ -134,6 +134,44 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
|
|
|
134
134
|
rmSync(box.root, { recursive: true, force: true });
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test('capture and restore recover an additional host settings file', () => {
|
|
138
|
+
const box = sandbox();
|
|
139
|
+
const gitConfigPath = join(box.root, '.gitconfig');
|
|
140
|
+
const codexHooksPath = join(box.root, '.codex', 'hooks.json');
|
|
141
|
+
writeFileSync(gitConfigPath, '');
|
|
142
|
+
writeFileWithParents(box.settingsPath, '{"host":"claude"}\n');
|
|
143
|
+
writeFileWithParents(codexHooksPath, '{"host":"codex"}\n');
|
|
144
|
+
const io = {
|
|
145
|
+
env: {
|
|
146
|
+
...process.env,
|
|
147
|
+
CDE_INSTALL_PSTACK: '0',
|
|
148
|
+
HOME: box.root,
|
|
149
|
+
USERPROFILE: box.root,
|
|
150
|
+
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
151
|
+
},
|
|
152
|
+
execFileSync,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const snapshot = capturePriorInstallSnapshot({
|
|
156
|
+
managedRoot: box.managedRoot,
|
|
157
|
+
manifestFilePath: box.manifestFilePath,
|
|
158
|
+
settingsPath: box.settingsPath,
|
|
159
|
+
additionalSettingsPaths: [codexHooksPath],
|
|
160
|
+
priorManifestFiles: [],
|
|
161
|
+
journalParentDirectory: box.journalParent,
|
|
162
|
+
io,
|
|
163
|
+
});
|
|
164
|
+
writeFileSync(box.settingsPath, '{"host":"changed"}\n');
|
|
165
|
+
writeFileSync(codexHooksPath, '{"host":"changed"}\n');
|
|
166
|
+
|
|
167
|
+
restorePriorInstallSnapshot(snapshot, { io });
|
|
168
|
+
|
|
169
|
+
assert.equal(readFileSync(box.settingsPath, 'utf8'), '{"host":"claude"}\n');
|
|
170
|
+
assert.equal(readFileSync(codexHooksPath, 'utf8'), '{"host":"codex"}\n');
|
|
171
|
+
discardInstallTransactionJournal(snapshot);
|
|
172
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
137
175
|
test('runWithInstallTransaction restores prior state on injected fault', () => {
|
|
138
176
|
const box = sandbox();
|
|
139
177
|
const gitConfigPath = join(box.root, '.gitconfig');
|
|
@@ -312,12 +350,16 @@ test('installer fault after_settings_write restores prior settings and files', (
|
|
|
312
350
|
try {
|
|
313
351
|
const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
|
|
314
352
|
const priorSettings = readFileSync(join(claudeDirectory, 'settings.json'), 'utf8');
|
|
353
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
354
|
+
writeFileWithParents(codexHooksPath, '{"hooks":{"PreToolUse":[{"matcher":"custom"}]}}\n');
|
|
355
|
+
const priorCodexHooks = readFileSync(codexHooksPath, 'utf8');
|
|
315
356
|
|
|
316
357
|
const failedRun = runInstaller(homeDirectory, [], {
|
|
317
358
|
faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
|
|
318
359
|
});
|
|
319
360
|
assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
|
|
320
361
|
assert.equal(readFileSync(join(claudeDirectory, 'settings.json'), 'utf8'), priorSettings);
|
|
362
|
+
assert.equal(readFileSync(codexHooksPath, 'utf8'), priorCodexHooks);
|
|
321
363
|
assert.equal(readFileSync(rulesFile, 'utf8'), 'prior-body\n');
|
|
322
364
|
assert.match(
|
|
323
365
|
readFileSync(join(claudeDirectory, '.claude-dev-env-manifest.json'), 'utf8'),
|
|
@@ -328,6 +370,25 @@ test('installer fault after_settings_write restores prior settings and files', (
|
|
|
328
370
|
}
|
|
329
371
|
});
|
|
330
372
|
|
|
373
|
+
test('installer fault after_settings_write removes a newly created Codex hooks file', () => {
|
|
374
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-absent-codex-hooks-'));
|
|
375
|
+
try {
|
|
376
|
+
const { claudeDirectory } = seedPriorInstall(homeDirectory);
|
|
377
|
+
const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
|
|
378
|
+
const failedRun = runInstaller(homeDirectory, [], {
|
|
379
|
+
faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
|
|
380
|
+
});
|
|
381
|
+
assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
|
|
382
|
+
assert.equal(existsSync(codexHooksPath), false);
|
|
383
|
+
assert.equal(
|
|
384
|
+
existsSync(join(claudeDirectory, '.claude-dev-env-manifest.json')),
|
|
385
|
+
true,
|
|
386
|
+
);
|
|
387
|
+
} finally {
|
|
388
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
331
392
|
test('installer fault after_manifest_write restores prior manifest', () => {
|
|
332
393
|
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-manifest-'));
|
|
333
394
|
try {
|
|
@@ -404,4 +465,3 @@ test('successful install after prior install leaves one manifest and no fault',
|
|
|
404
465
|
rmSync(homeDirectory, { recursive: true, force: true });
|
|
405
466
|
}
|
|
406
467
|
});
|
|
407
|
-
|