mover-os 4.7.3 → 4.7.5
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/install.js +439 -14
- package/package.json +1 -1
package/install.js
CHANGED
|
@@ -17,6 +17,18 @@ const { execSync } = require("child_process");
|
|
|
17
17
|
|
|
18
18
|
const VERSION = "4";
|
|
19
19
|
|
|
20
|
+
// ─── Windows path normalization ─────────────────────────────────────────────
|
|
21
|
+
// Git Bash / MSYS / WSL surface paths like "/c/Users/foo" inside terminals
|
|
22
|
+
// that Node treats as drive-less when run from PowerShell or cmd. path.resolve
|
|
23
|
+
// then prepends the *current* drive, producing "C:\c\Users\foo". Convert
|
|
24
|
+
// "/c/Users/foo" → "C:\Users\foo" before any path.join(vaultPath, ...) call.
|
|
25
|
+
function normalizeWinPath(p) {
|
|
26
|
+
if (process.platform !== "win32" || !p) return p;
|
|
27
|
+
const m = p.match(/^\/([a-zA-Z])\/(.*)$/);
|
|
28
|
+
if (m) return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
// ─── JSON output helper ──────────────────────────────────────────────────────
|
|
21
33
|
function jsonOut(command, data, ok = true) {
|
|
22
34
|
const envelope = {
|
|
@@ -1053,12 +1065,14 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
|
|
|
1053
1065
|
|
|
1054
1066
|
if (fs.existsSync(wfSrc)) {
|
|
1055
1067
|
for (const file of fs.readdirSync(wfSrc).filter((f) => f.endsWith(".md"))) {
|
|
1056
|
-
const srcContent = fs.readFileSync(path.join(wfSrc, file), "utf8")
|
|
1068
|
+
const srcContent = fs.readFileSync(path.join(wfSrc, file), "utf8")
|
|
1069
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1057
1070
|
const destFile = wfDest && path.join(wfDest, file);
|
|
1058
1071
|
if (!destFile || !fs.existsSync(destFile)) {
|
|
1059
1072
|
result.workflows.push({ file, status: "new" });
|
|
1060
1073
|
} else {
|
|
1061
|
-
const destContent = fs.readFileSync(destFile, "utf8")
|
|
1074
|
+
const destContent = fs.readFileSync(destFile, "utf8")
|
|
1075
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1062
1076
|
result.workflows.push({
|
|
1063
1077
|
file,
|
|
1064
1078
|
status: srcContent === destContent ? "unchanged" : "changed",
|
|
@@ -1097,8 +1111,10 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
|
|
|
1097
1111
|
].filter(Boolean);
|
|
1098
1112
|
const rulesDest = rulesDests.find((d) => fs.existsSync(d));
|
|
1099
1113
|
if (fs.existsSync(rulesSrc) && rulesDest) {
|
|
1100
|
-
const srcContent = fs.readFileSync(rulesSrc, "utf8")
|
|
1101
|
-
|
|
1114
|
+
const srcContent = fs.readFileSync(rulesSrc, "utf8")
|
|
1115
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1116
|
+
const destContent = fs.readFileSync(rulesDest, "utf8")
|
|
1117
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1102
1118
|
result.rules = srcContent === destContent ? "unchanged" : "changed";
|
|
1103
1119
|
} else {
|
|
1104
1120
|
result.rules = "unchanged";
|
|
@@ -1115,12 +1131,14 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
|
|
|
1115
1131
|
} else {
|
|
1116
1132
|
const relNorm = entryRel.replace(/\\/g, "/");
|
|
1117
1133
|
if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
|
|
1118
|
-
const srcContent = fs.readFileSync(path.join(dir, entry.name), "utf8")
|
|
1134
|
+
const srcContent = fs.readFileSync(path.join(dir, entry.name), "utf8")
|
|
1135
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1119
1136
|
const destFile = path.join(vaultPath, entryRel);
|
|
1120
1137
|
if (!fs.existsSync(destFile)) {
|
|
1121
1138
|
result.templates.push({ file: entryRel, status: "new" });
|
|
1122
1139
|
} else {
|
|
1123
|
-
const destContent = fs.readFileSync(destFile, "utf8")
|
|
1140
|
+
const destContent = fs.readFileSync(destFile, "utf8")
|
|
1141
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1124
1142
|
result.templates.push({
|
|
1125
1143
|
file: entryRel,
|
|
1126
1144
|
status: srcContent === destContent ? "unchanged" : "changed",
|
|
@@ -1147,12 +1165,14 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
|
|
|
1147
1165
|
if (entry.isDirectory()) {
|
|
1148
1166
|
const skillFile = path.join(full, "SKILL.md");
|
|
1149
1167
|
if (fs.existsSync(skillFile)) {
|
|
1150
|
-
const srcContent = fs.readFileSync(skillFile, "utf8")
|
|
1168
|
+
const srcContent = fs.readFileSync(skillFile, "utf8")
|
|
1169
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1151
1170
|
const destSkill = path.join(skillsDest, entry.name, "SKILL.md");
|
|
1152
1171
|
if (!fs.existsSync(destSkill)) {
|
|
1153
1172
|
result.skills.push({ file: entry.name, status: "new" });
|
|
1154
1173
|
} else {
|
|
1155
|
-
const destContent = fs.readFileSync(destSkill, "utf8")
|
|
1174
|
+
const destContent = fs.readFileSync(destSkill, "utf8")
|
|
1175
|
+
.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1156
1176
|
result.skills.push({ file: entry.name, status: srcContent === destContent ? "unchanged" : "changed" });
|
|
1157
1177
|
}
|
|
1158
1178
|
} else {
|
|
@@ -1903,13 +1923,13 @@ const AGENT_REGISTRY = {
|
|
|
1903
1923
|
// ── Enhanced Tier ──────────────────────────────────────────────────────────
|
|
1904
1924
|
"codex": {
|
|
1905
1925
|
name: "Codex",
|
|
1906
|
-
tier: "
|
|
1907
|
-
tierDesc: "AGENTS.md, skills (skills = commands)",
|
|
1926
|
+
tier: "full",
|
|
1927
|
+
tierDesc: "AGENTS.md, skills (skills = commands), 5 hooks",
|
|
1908
1928
|
detect: () => cmdExists("codex") || fs.existsSync(path.join(H, ".codex")),
|
|
1909
1929
|
rules: { type: "agents-md", dest: () => path.join(H, ".codex", "AGENTS.md") },
|
|
1910
1930
|
skills: { dest: () => path.join(H, ".codex", "skills") },
|
|
1911
1931
|
commands: null,
|
|
1912
|
-
hooks:
|
|
1932
|
+
hooks: { type: "codex-hooks-json", dest: () => path.join(H, ".codex", "hooks.json") },
|
|
1913
1933
|
},
|
|
1914
1934
|
"antigravity": {
|
|
1915
1935
|
name: "Antigravity",
|
|
@@ -2220,6 +2240,167 @@ Stuck: /debug-resistance
|
|
|
2220
2240
|
}
|
|
2221
2241
|
|
|
2222
2242
|
// ─── Claude Code hooks (settings.json) ──────────────────────────────────────
|
|
2243
|
+
// ─── Codex hook config generator ────────────────────────────────────────────
|
|
2244
|
+
// v4.7.5: Codex hooks invoked through mover-hook-adapter.js for schema
|
|
2245
|
+
// translation. MVP scope: session-start, engine-protection, git-safety,
|
|
2246
|
+
// plan-sync-reminder, dirty-tree-guard (no session-log-reminder under Codex
|
|
2247
|
+
// — that script is Claude-transcript-specific).
|
|
2248
|
+
//
|
|
2249
|
+
// IMPORTANT: Codex runs hook commands through cmd.exe on Windows, which does
|
|
2250
|
+
// NOT expand $HOME. We resolve absolute paths at install time so the same
|
|
2251
|
+
// hooks.json works on macOS/Linux/Windows.
|
|
2252
|
+
function generateCodexHooks() {
|
|
2253
|
+
const home = os.homedir();
|
|
2254
|
+
// Forward slashes work on all three OSes when invoking node directly.
|
|
2255
|
+
const fwd = (p) => p.split(path.sep).join("/");
|
|
2256
|
+
const hooksRoot = fwd(path.join(home, ".codex", "hooks"));
|
|
2257
|
+
const adapter = `"${hooksRoot}/mover-hook-adapter.js"`;
|
|
2258
|
+
const hookDir = `"${hooksRoot}`;
|
|
2259
|
+
return JSON.stringify(
|
|
2260
|
+
{
|
|
2261
|
+
hooks: {
|
|
2262
|
+
SessionStart: [
|
|
2263
|
+
{
|
|
2264
|
+
matcher: "startup|resume|clear",
|
|
2265
|
+
hooks: [
|
|
2266
|
+
{
|
|
2267
|
+
type: "command",
|
|
2268
|
+
command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" full`,
|
|
2269
|
+
timeout: 5,
|
|
2270
|
+
},
|
|
2271
|
+
],
|
|
2272
|
+
},
|
|
2273
|
+
],
|
|
2274
|
+
PreToolUse: [
|
|
2275
|
+
{
|
|
2276
|
+
matcher: "Bash",
|
|
2277
|
+
hooks: [
|
|
2278
|
+
{
|
|
2279
|
+
type: "command",
|
|
2280
|
+
command: `node ${adapter} codex PreToolUse ${hookDir}/git-safety.sh"`,
|
|
2281
|
+
timeout: 5,
|
|
2282
|
+
},
|
|
2283
|
+
],
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
matcher: "Edit|Write|apply_patch",
|
|
2287
|
+
hooks: [
|
|
2288
|
+
{
|
|
2289
|
+
type: "command",
|
|
2290
|
+
command: `node ${adapter} codex PreToolUse ${hookDir}/engine-protection.sh"`,
|
|
2291
|
+
timeout: 5,
|
|
2292
|
+
},
|
|
2293
|
+
],
|
|
2294
|
+
},
|
|
2295
|
+
],
|
|
2296
|
+
PostToolUse: [
|
|
2297
|
+
{
|
|
2298
|
+
matcher: "Edit|Write|apply_patch",
|
|
2299
|
+
hooks: [
|
|
2300
|
+
{
|
|
2301
|
+
type: "command",
|
|
2302
|
+
command: `node ${adapter} codex PostToolUse ${hookDir}/plan-sync-reminder.sh"`,
|
|
2303
|
+
timeout: 5,
|
|
2304
|
+
},
|
|
2305
|
+
],
|
|
2306
|
+
},
|
|
2307
|
+
],
|
|
2308
|
+
Stop: [
|
|
2309
|
+
{
|
|
2310
|
+
hooks: [
|
|
2311
|
+
{
|
|
2312
|
+
type: "command",
|
|
2313
|
+
command: `node ${adapter} codex Stop ${hookDir}/dirty-tree-guard.sh"`,
|
|
2314
|
+
timeout: 10,
|
|
2315
|
+
},
|
|
2316
|
+
],
|
|
2317
|
+
},
|
|
2318
|
+
],
|
|
2319
|
+
},
|
|
2320
|
+
},
|
|
2321
|
+
null,
|
|
2322
|
+
2
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// ─── Gemini hook config generator ───────────────────────────────────────────
|
|
2327
|
+
// v4.7.5: Gemini events differ from Claude/Codex — UserPromptSubmit→BeforeAgent,
|
|
2328
|
+
// PreToolUse→BeforeTool, PostToolUse→AfterTool, Stop→AfterAgent. Adapter
|
|
2329
|
+
// translates schema; we map event names here.
|
|
2330
|
+
// Timeouts are in milliseconds per Gemini hook spec (default 60000).
|
|
2331
|
+
// Absolute paths used so cmd.exe on Windows can resolve correctly.
|
|
2332
|
+
function generateGeminiHooks() {
|
|
2333
|
+
const home = os.homedir();
|
|
2334
|
+
const fwd = (p) => p.split(path.sep).join("/");
|
|
2335
|
+
const hooksRoot = fwd(path.join(home, ".gemini", "hooks"));
|
|
2336
|
+
const adapter = `"${hooksRoot}/mover-hook-adapter.js"`;
|
|
2337
|
+
const hookDir = `"${hooksRoot}`;
|
|
2338
|
+
return {
|
|
2339
|
+
SessionStart: [
|
|
2340
|
+
{
|
|
2341
|
+
matcher: "startup",
|
|
2342
|
+
hooks: [
|
|
2343
|
+
{
|
|
2344
|
+
name: "mover-session-start",
|
|
2345
|
+
type: "command",
|
|
2346
|
+
command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh" full`,
|
|
2347
|
+
timeout: 5000,
|
|
2348
|
+
},
|
|
2349
|
+
],
|
|
2350
|
+
},
|
|
2351
|
+
],
|
|
2352
|
+
BeforeTool: [
|
|
2353
|
+
{
|
|
2354
|
+
matcher: "write_file|replace",
|
|
2355
|
+
hooks: [
|
|
2356
|
+
{
|
|
2357
|
+
name: "mover-engine-protection",
|
|
2358
|
+
type: "command",
|
|
2359
|
+
command: `node ${adapter} gemini BeforeTool ${hookDir}/engine-protection.sh"`,
|
|
2360
|
+
timeout: 5000,
|
|
2361
|
+
},
|
|
2362
|
+
],
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
matcher: "run_shell_command",
|
|
2366
|
+
hooks: [
|
|
2367
|
+
{
|
|
2368
|
+
name: "mover-git-safety",
|
|
2369
|
+
type: "command",
|
|
2370
|
+
command: `node ${adapter} gemini BeforeTool ${hookDir}/git-safety.sh"`,
|
|
2371
|
+
timeout: 5000,
|
|
2372
|
+
},
|
|
2373
|
+
],
|
|
2374
|
+
},
|
|
2375
|
+
],
|
|
2376
|
+
AfterTool: [
|
|
2377
|
+
{
|
|
2378
|
+
matcher: "write_file|replace",
|
|
2379
|
+
hooks: [
|
|
2380
|
+
{
|
|
2381
|
+
name: "mover-plan-sync",
|
|
2382
|
+
type: "command",
|
|
2383
|
+
command: `node ${adapter} gemini AfterTool ${hookDir}/plan-sync-reminder.sh"`,
|
|
2384
|
+
timeout: 5000,
|
|
2385
|
+
},
|
|
2386
|
+
],
|
|
2387
|
+
},
|
|
2388
|
+
],
|
|
2389
|
+
AfterAgent: [
|
|
2390
|
+
{
|
|
2391
|
+
hooks: [
|
|
2392
|
+
{
|
|
2393
|
+
name: "mover-dirty-tree-guard",
|
|
2394
|
+
type: "command",
|
|
2395
|
+
command: `node ${adapter} gemini AfterAgent ${hookDir}/dirty-tree-guard.sh"`,
|
|
2396
|
+
timeout: 10000,
|
|
2397
|
+
},
|
|
2398
|
+
],
|
|
2399
|
+
},
|
|
2400
|
+
],
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2223
2404
|
function generateClaudeSettings() {
|
|
2224
2405
|
return JSON.stringify(
|
|
2225
2406
|
{
|
|
@@ -2949,6 +3130,227 @@ function installHooksForClaude(bundleDir, vaultPath) {
|
|
|
2949
3130
|
return count;
|
|
2950
3131
|
}
|
|
2951
3132
|
|
|
3133
|
+
// ─── Multi-agent hook installer (v4.7.5) ────────────────────────────────────
|
|
3134
|
+
// Copies the MVP hook set (5 enforcement hooks + adapter + shared lib) to the
|
|
3135
|
+
// agent's hook directory and writes its hook config. Used by Codex and Gemini.
|
|
3136
|
+
const MVP_HOOK_SCRIPTS = [
|
|
3137
|
+
"session-start.sh",
|
|
3138
|
+
"engine-protection.sh",
|
|
3139
|
+
"git-safety.sh",
|
|
3140
|
+
"plan-sync-reminder.sh",
|
|
3141
|
+
"dirty-tree-guard.sh",
|
|
3142
|
+
"mover-lib.sh", // sourced by the others
|
|
3143
|
+
];
|
|
3144
|
+
|
|
3145
|
+
// Section-aware TOML upsert. Sets [section] key = value, preserving comments,
|
|
3146
|
+
// existing keys, and other sections. If section exists with the key set to a
|
|
3147
|
+
// different value, the value is REPLACED (not duplicated). If the section
|
|
3148
|
+
// header has whitespace variants like `[ features ]`, treat as the same section.
|
|
3149
|
+
function upsertTomlKey(filePath, section, key, value) {
|
|
3150
|
+
let content = "";
|
|
3151
|
+
if (fs.existsSync(filePath)) {
|
|
3152
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// Match table header in any whitespace variant: [section], [ section ], etc.
|
|
3156
|
+
// Also tolerate trailing comment after header: [section] # ...
|
|
3157
|
+
const headerRe = (name) =>
|
|
3158
|
+
new RegExp(
|
|
3159
|
+
`^\\s*\\[\\s*${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*\\](?:[^\\n]*)$`,
|
|
3160
|
+
"m"
|
|
3161
|
+
);
|
|
3162
|
+
// Any section header (used to find section boundaries)
|
|
3163
|
+
const anyHeaderRe = /^\s*\[\s*[^\]]+\s*\](?:[^\n]*)$/m;
|
|
3164
|
+
|
|
3165
|
+
const lines = content.split("\n");
|
|
3166
|
+
let inSection = false;
|
|
3167
|
+
let sectionStart = -1;
|
|
3168
|
+
let sectionEnd = lines.length;
|
|
3169
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3170
|
+
if (headerRe(section).test(lines[i])) {
|
|
3171
|
+
inSection = true;
|
|
3172
|
+
sectionStart = i;
|
|
3173
|
+
// Find next header (or EOF) — that's section end
|
|
3174
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
3175
|
+
if (anyHeaderRe.test(lines[j])) {
|
|
3176
|
+
sectionEnd = j;
|
|
3177
|
+
break;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
break;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
const newKv = `${key} = ${value}`;
|
|
3185
|
+
// Match existing key in this section: tolerates whitespace + comment
|
|
3186
|
+
const keyRe = new RegExp(
|
|
3187
|
+
`^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*=.*$`
|
|
3188
|
+
);
|
|
3189
|
+
|
|
3190
|
+
if (inSection) {
|
|
3191
|
+
let replaced = false;
|
|
3192
|
+
for (let i = sectionStart + 1; i < sectionEnd; i++) {
|
|
3193
|
+
if (keyRe.test(lines[i])) {
|
|
3194
|
+
lines[i] = newKv;
|
|
3195
|
+
replaced = true;
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (!replaced) {
|
|
3200
|
+
// Insert key right after section header
|
|
3201
|
+
lines.splice(sectionStart + 1, 0, newKv);
|
|
3202
|
+
}
|
|
3203
|
+
} else {
|
|
3204
|
+
// Section absent — append fresh section at EOF
|
|
3205
|
+
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
|
3206
|
+
lines.push("");
|
|
3207
|
+
}
|
|
3208
|
+
lines.push(`[${section}]`);
|
|
3209
|
+
lines.push(newKv);
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
fs.writeFileSync(filePath, lines.join("\n"), "utf8");
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
function copyMvpHooks(bundleDir, destDir) {
|
|
3216
|
+
const hooksSrc = path.join(bundleDir, "src", "hooks");
|
|
3217
|
+
if (!fs.existsSync(hooksSrc)) return 0;
|
|
3218
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
3219
|
+
let count = 0;
|
|
3220
|
+
for (const file of MVP_HOOK_SCRIPTS) {
|
|
3221
|
+
const src = path.join(hooksSrc, file);
|
|
3222
|
+
if (!fs.existsSync(src)) continue;
|
|
3223
|
+
const dst = path.join(destDir, file);
|
|
3224
|
+
const content = fs
|
|
3225
|
+
.readFileSync(src, "utf8")
|
|
3226
|
+
.replace(/\r\n/g, "\n")
|
|
3227
|
+
.replace(/\r/g, "\n");
|
|
3228
|
+
fs.writeFileSync(dst, content, { mode: 0o755 });
|
|
3229
|
+
count++;
|
|
3230
|
+
}
|
|
3231
|
+
// Adapter (Node script — copy preserving binary mode)
|
|
3232
|
+
const adapterSrc = path.join(hooksSrc, "mover-hook-adapter.js");
|
|
3233
|
+
if (fs.existsSync(adapterSrc)) {
|
|
3234
|
+
const dst = path.join(destDir, "mover-hook-adapter.js");
|
|
3235
|
+
const content = fs
|
|
3236
|
+
.readFileSync(adapterSrc, "utf8")
|
|
3237
|
+
.replace(/\r\n/g, "\n")
|
|
3238
|
+
.replace(/\r/g, "\n");
|
|
3239
|
+
fs.writeFileSync(dst, content, { mode: 0o755 });
|
|
3240
|
+
count++;
|
|
3241
|
+
}
|
|
3242
|
+
return count;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
function installHooksForCodex(bundleDir) {
|
|
3246
|
+
const home = os.homedir();
|
|
3247
|
+
const codexDir = path.join(home, ".codex");
|
|
3248
|
+
const hooksDst = path.join(codexDir, "hooks");
|
|
3249
|
+
|
|
3250
|
+
// Detect Codex install
|
|
3251
|
+
if (!fs.existsSync(codexDir) && !cmdExists("codex")) return 0;
|
|
3252
|
+
|
|
3253
|
+
fs.mkdirSync(codexDir, { recursive: true });
|
|
3254
|
+
const count = copyMvpHooks(bundleDir, hooksDst);
|
|
3255
|
+
if (count === 0) return 0;
|
|
3256
|
+
|
|
3257
|
+
// Write hooks.json (deep-merge if existing).
|
|
3258
|
+
// v4.7.5 fix: per-entry idempotency, not per-event. Previous coarse check
|
|
3259
|
+
// skipped sibling entries when one Mover hook was already registered.
|
|
3260
|
+
const hooksJsonPath = path.join(codexDir, "hooks.json");
|
|
3261
|
+
const newConfig = JSON.parse(generateCodexHooks());
|
|
3262
|
+
if (fs.existsSync(hooksJsonPath)) {
|
|
3263
|
+
try {
|
|
3264
|
+
const existing = JSON.parse(fs.readFileSync(hooksJsonPath, "utf8"));
|
|
3265
|
+
mergeHooksConfig(existing, newConfig);
|
|
3266
|
+
fs.writeFileSync(hooksJsonPath, JSON.stringify(existing, null, 2), "utf8");
|
|
3267
|
+
} catch {
|
|
3268
|
+
try {
|
|
3269
|
+
fs.copyFileSync(hooksJsonPath, hooksJsonPath + ".bak");
|
|
3270
|
+
} catch {}
|
|
3271
|
+
fs.writeFileSync(hooksJsonPath, JSON.stringify(newConfig, null, 2), "utf8");
|
|
3272
|
+
}
|
|
3273
|
+
} else {
|
|
3274
|
+
fs.writeFileSync(hooksJsonPath, JSON.stringify(newConfig, null, 2), "utf8");
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// Enable codex_hooks feature in config.toml (section-aware upsert).
|
|
3278
|
+
// Handles edge cases the prior regex approach missed:
|
|
3279
|
+
// [features] # comment — replacement misses the table header
|
|
3280
|
+
// codex_hooks = false — would create duplicate key
|
|
3281
|
+
// [ features ] — bare-line regex misses whitespace variant
|
|
3282
|
+
// Strategy: parse file into sections, locate or create [features],
|
|
3283
|
+
// upsert codex_hooks=true within that section, reassemble.
|
|
3284
|
+
const configToml = path.join(codexDir, "config.toml");
|
|
3285
|
+
upsertTomlKey(configToml, "features", "codex_hooks", "true");
|
|
3286
|
+
|
|
3287
|
+
return count;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
function installHooksForGemini(bundleDir) {
|
|
3291
|
+
const home = os.homedir();
|
|
3292
|
+
const geminiDir = path.join(home, ".gemini");
|
|
3293
|
+
const hooksDst = path.join(geminiDir, "hooks");
|
|
3294
|
+
|
|
3295
|
+
// Detect Gemini install
|
|
3296
|
+
if (
|
|
3297
|
+
!fs.existsSync(geminiDir) &&
|
|
3298
|
+
!cmdExists("gemini") &&
|
|
3299
|
+
!fs.existsSync(path.join(geminiDir, "settings.json"))
|
|
3300
|
+
)
|
|
3301
|
+
return 0;
|
|
3302
|
+
|
|
3303
|
+
fs.mkdirSync(geminiDir, { recursive: true });
|
|
3304
|
+
const count = copyMvpHooks(bundleDir, hooksDst);
|
|
3305
|
+
if (count === 0) return 0;
|
|
3306
|
+
|
|
3307
|
+
// Deep-merge into settings.json
|
|
3308
|
+
const settingsPath = path.join(geminiDir, "settings.json");
|
|
3309
|
+
const newHooks = generateGeminiHooks();
|
|
3310
|
+
let existing = {};
|
|
3311
|
+
if (fs.existsSync(settingsPath)) {
|
|
3312
|
+
try {
|
|
3313
|
+
existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
3314
|
+
} catch {
|
|
3315
|
+
try {
|
|
3316
|
+
fs.copyFileSync(settingsPath, settingsPath + ".bak");
|
|
3317
|
+
} catch {}
|
|
3318
|
+
existing = {};
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
if (!existing.hooks) existing.hooks = {};
|
|
3322
|
+
mergeHooksConfig(existing, { hooks: newHooks });
|
|
3323
|
+
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8");
|
|
3324
|
+
|
|
3325
|
+
return count;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
// Per-entry hook merge. For each new entry, check if an entry with the SAME
|
|
3329
|
+
// matcher AND a Mover adapter command already exists. Skip only that exact
|
|
3330
|
+
// duplicate. Sibling entries (different matchers, or non-Mover entries) are
|
|
3331
|
+
// left intact.
|
|
3332
|
+
function mergeHooksConfig(existing, newConfig) {
|
|
3333
|
+
if (!existing.hooks) existing.hooks = {};
|
|
3334
|
+
for (const [event, newEntries] of Object.entries(newConfig.hooks || {})) {
|
|
3335
|
+
if (!existing.hooks[event]) {
|
|
3336
|
+
existing.hooks[event] = newEntries;
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
3339
|
+
for (const newEntry of newEntries) {
|
|
3340
|
+
const matcher = newEntry.matcher; // may be undefined
|
|
3341
|
+
const newCmds = (newEntry.hooks || []).map((h) => h.command || "");
|
|
3342
|
+
const isDup = existing.hooks[event].some((existingEntry) => {
|
|
3343
|
+
if ((existingEntry.matcher || "") !== (matcher || "")) return false;
|
|
3344
|
+
const existingCmds = (existingEntry.hooks || []).map(
|
|
3345
|
+
(h) => h.command || ""
|
|
3346
|
+
);
|
|
3347
|
+
return newCmds.every((c) => existingCmds.includes(c));
|
|
3348
|
+
});
|
|
3349
|
+
if (!isDup) existing.hooks[event].push(newEntry);
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
|
|
2952
3354
|
// ─── Per-agent install orchestrators ────────────────────────────────────────
|
|
2953
3355
|
function installClaudeCode(bundleDir, vaultPath, skillOpts) {
|
|
2954
3356
|
const home = os.homedir();
|
|
@@ -3102,6 +3504,12 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
|
|
|
3102
3504
|
if (skCount > 0) steps.push(`${skCount} skills`);
|
|
3103
3505
|
}
|
|
3104
3506
|
|
|
3507
|
+
// v4.7.5: Native Codex hook support via mover-hook-adapter.js
|
|
3508
|
+
if (!skillOpts?.skipHooks) {
|
|
3509
|
+
const hkCount = installHooksForCodex(bundleDir);
|
|
3510
|
+
if (hkCount > 0) steps.push(`${hkCount} hooks`);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3105
3513
|
return steps;
|
|
3106
3514
|
}
|
|
3107
3515
|
|
|
@@ -3163,6 +3571,12 @@ function installGeminiCli(bundleDir, vaultPath, skillOpts, writtenFiles) {
|
|
|
3163
3571
|
if (skCount > 0) steps.push(`${skCount} skills`);
|
|
3164
3572
|
}
|
|
3165
3573
|
|
|
3574
|
+
// v4.7.5: Native Gemini hook support via mover-hook-adapter.js
|
|
3575
|
+
if (!skillOpts?.skipHooks) {
|
|
3576
|
+
const hkCount = installHooksForGemini(bundleDir);
|
|
3577
|
+
if (hkCount > 0) steps.push(`${hkCount} hooks`);
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3166
3580
|
return steps;
|
|
3167
3581
|
}
|
|
3168
3582
|
|
|
@@ -5280,8 +5694,12 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
5280
5694
|
}
|
|
5281
5695
|
}
|
|
5282
5696
|
fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
|
|
5283
|
-
writeMoverConfig(vaultPath, selectedIds);
|
|
5697
|
+
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
5284
5698
|
barLn();
|
|
5699
|
+
if (totalChanged > 0) {
|
|
5700
|
+
barLn(dim(" Restart your AI session to load updated rules and skills."));
|
|
5701
|
+
barLn();
|
|
5702
|
+
}
|
|
5285
5703
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
5286
5704
|
await successAnimation(`${totalChanged} files updated in ${elapsed}s.`);
|
|
5287
5705
|
return;
|
|
@@ -5610,7 +6028,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
5610
6028
|
}
|
|
5611
6029
|
}
|
|
5612
6030
|
saveUpdateManifest(newManifest);
|
|
5613
|
-
writeMoverConfig(vaultPath, selectedIds);
|
|
6031
|
+
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
5614
6032
|
|
|
5615
6033
|
// ── Summary ──
|
|
5616
6034
|
barLn();
|
|
@@ -5630,6 +6048,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
5630
6048
|
}
|
|
5631
6049
|
|
|
5632
6050
|
barLn();
|
|
6051
|
+
if (updated.length > 0 || autoMerged.length > 0) {
|
|
6052
|
+
barLn(dim(" Restart your AI session to load updated rules and skills."));
|
|
6053
|
+
barLn();
|
|
6054
|
+
}
|
|
5633
6055
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
5634
6056
|
if (needsUpdate) {
|
|
5635
6057
|
outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}. ${dim(`(${elapsed}s)`)}`);
|
|
@@ -5643,13 +6065,15 @@ function resolveVaultPath(explicitVault) {
|
|
|
5643
6065
|
if (explicitVault) {
|
|
5644
6066
|
let v = explicitVault;
|
|
5645
6067
|
if (v.startsWith("~")) v = path.join(os.homedir(), v.slice(1));
|
|
6068
|
+
v = normalizeWinPath(v);
|
|
5646
6069
|
return path.resolve(v);
|
|
5647
6070
|
}
|
|
5648
6071
|
// Try config.json
|
|
5649
6072
|
const cfgPath = path.join(os.homedir(), ".mover", "config.json");
|
|
5650
6073
|
if (fs.existsSync(cfgPath)) {
|
|
5651
6074
|
try {
|
|
5652
|
-
|
|
6075
|
+
let v = JSON.parse(fs.readFileSync(cfgPath, "utf8")).vaultPath;
|
|
6076
|
+
v = normalizeWinPath(v);
|
|
5653
6077
|
if (v && fs.existsSync(v)) return v;
|
|
5654
6078
|
} catch {}
|
|
5655
6079
|
}
|
|
@@ -5913,6 +6337,7 @@ async function main() {
|
|
|
5913
6337
|
}
|
|
5914
6338
|
|
|
5915
6339
|
if (vaultPath.startsWith("~")) vaultPath = path.join(os.homedir(), vaultPath.slice(1));
|
|
6340
|
+
vaultPath = normalizeWinPath(vaultPath);
|
|
5916
6341
|
vaultPath = path.resolve(vaultPath);
|
|
5917
6342
|
|
|
5918
6343
|
// ── Fresh install only — redirect if existing ──
|