mover-os 4.7.5 → 4.7.6

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.
Files changed (2) hide show
  1. package/install.js +325 -53
  2. package/package.json +1 -1
package/install.js CHANGED
@@ -734,7 +734,15 @@ async function activateKey(key) {
734
734
  if (!key) return;
735
735
  try {
736
736
  const https = require("https");
737
- const body = JSON.stringify({ key: key.trim(), organization_id: POLAR_ORG_ID, label: os.hostname() });
737
+ // v4.7.6: label = stable machine_id (hardware-derived), not os.hostname().
738
+ // Hostnames change (rename a Mac, switch wifi, container restart) and the
739
+ // server-side activation cap counts each hostname as a different machine.
740
+ // machine_id is stable across hostname changes and matches what the
741
+ // server also stores for /api/download X-Machine-Id correlation.
742
+ const moverDir = path.join(os.homedir(), ".mover");
743
+ let label = os.hostname();
744
+ try { label = getMachineId(moverDir) || label; } catch {}
745
+ const body = JSON.stringify({ key: key.trim(), organization_id: POLAR_ORG_ID, label });
738
746
  await new Promise((resolve, reject) => {
739
747
  const req = https.request({
740
748
  hostname: "api.polar.sh",
@@ -822,11 +830,30 @@ async function downloadPayload(key) {
822
830
  method: "GET",
823
831
  timeout: 60000,
824
832
  }, (res2) => {
825
- const chunks = [];
826
- res2.on("data", (c) => chunks.push(c));
833
+ // v4.7.6 (post-audit): stream-and-abort with running byte counter
834
+ // instead of buffering then checking size. A malicious upstream
835
+ // could send GBs of data and OOM Node before the post-download
836
+ // 100MB stat check fires. Now we destroy the connection the
837
+ // moment the running total exceeds MAX_TARBALL_BYTES.
838
+ const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
839
+ let total = 0;
840
+ const ws = fs.createWriteStream(tarPath);
841
+ let aborted = false;
842
+ res2.on("data", (c) => {
843
+ total += c.length;
844
+ if (total > MAX_TARBALL_BYTES) {
845
+ aborted = true;
846
+ try { res2.destroy(); } catch {}
847
+ try { ws.destroy(); } catch {}
848
+ try { fs.unlinkSync(tarPath); } catch {}
849
+ reject(new Error(`Payload exceeded ${MAX_TARBALL_BYTES} bytes during download`));
850
+ return;
851
+ }
852
+ ws.write(c);
853
+ });
827
854
  res2.on("end", () => {
828
- fs.writeFileSync(tarPath, Buffer.concat(chunks));
829
- resolve();
855
+ if (aborted) return;
856
+ ws.end(() => resolve());
830
857
  });
831
858
  });
832
859
  req2.on("error", reject);
@@ -850,11 +877,26 @@ async function downloadPayload(key) {
850
877
  reject(new Error(`Download failed (HTTP ${res.statusCode})`));
851
878
  return;
852
879
  }
853
- const chunks = [];
854
- res.on("data", (c) => chunks.push(c));
880
+ // v4.7.6 (post-audit): same stream-and-abort behavior as the redirect path.
881
+ const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
882
+ let total = 0;
883
+ const ws = fs.createWriteStream(tarPath);
884
+ let aborted = false;
885
+ res.on("data", (c) => {
886
+ total += c.length;
887
+ if (total > MAX_TARBALL_BYTES) {
888
+ aborted = true;
889
+ try { res.destroy(); } catch {}
890
+ try { ws.destroy(); } catch {}
891
+ try { fs.unlinkSync(tarPath); } catch {}
892
+ reject(new Error(`Payload exceeded ${MAX_TARBALL_BYTES} bytes during download`));
893
+ return;
894
+ }
895
+ ws.write(c);
896
+ });
855
897
  res.on("end", () => {
856
- fs.writeFileSync(tarPath, Buffer.concat(chunks));
857
- resolve();
898
+ if (aborted) return;
899
+ ws.end(() => resolve());
858
900
  });
859
901
  });
860
902
  req.on("error", reject);
@@ -862,10 +904,75 @@ async function downloadPayload(key) {
862
904
  req.end();
863
905
  });
864
906
 
865
- // Validate tar contents before extraction (zip-slip prevention)
866
- const listing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
867
- const entries = listing.split('\n').filter(Boolean);
868
- const badPaths = entries.filter(e => e.startsWith('/') || e.includes('..'));
907
+ // ── Validate tar contents before extraction ────────────────────────────
908
+ //
909
+ // SECURITY (v4.7.5 → v4.7.6): zip-slip hardening (commit 2d5cdd2) only
910
+ // checked path strings (absolute paths, `..` traversal). It did NOT check
911
+ // entry TYPE. A crafted tarball could embed a symlink (e.g.,
912
+ // `mover-link -> /etc/passwd`) which `tar -xzf` would happily extract,
913
+ // creating a file outside the destination dir on first follow.
914
+ //
915
+ // Fix: switch to `tar -tvzf` which prints the entry type as the first
916
+ // character (- regular, d directory, l symlink, h hardlink, c char dev,
917
+ // b block dev). Reject anything that isn't a regular file, directory, or
918
+ // long-link metadata (which is followed by a regular entry).
919
+ //
920
+ // Also enforce a 100MB hard cap (compressed) post-download — if the file
921
+ // on disk is bigger than that, refuse before extraction. v4.7.5 had no
922
+ // size check, so a 2GB tarball would OOM Node before validation.
923
+ const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
924
+ const tarStat = fs.statSync(tarPath);
925
+ if (tarStat.size > MAX_TARBALL_BYTES) {
926
+ fs.unlinkSync(tarPath);
927
+ throw new Error(`Payload too large: ${tarStat.size} bytes > ${MAX_TARBALL_BYTES}`);
928
+ }
929
+
930
+ // v4.7.6 (post-audit): use TWO tar listings.
931
+ //
932
+ // 1. `tar -tzf` → paths-only output (one path per line, exact). Used to
933
+ // validate against absolute paths and `..` traversal. v4.7.5's
934
+ // paths-only validation worked here, the regression was the missing
935
+ // type check, not the path parser.
936
+ //
937
+ // 2. `tar -tvzf` → verbose output. Used ONLY to extract the type
938
+ // character (first column). We intentionally do NOT parse the path
939
+ // field from verbose output because BSD tar's verbose format is
940
+ // fragile when paths contain spaces (e.g., "_Template Project/Chats &
941
+ // Resources/...") — last-whitespace-token parsing would silently
942
+ // drop the leading parts of a multi-word path.
943
+ //
944
+ // The ordering of -tvzf and -tzf output is identical (tar walks the
945
+ // archive in the same order), so we zip them by line index for the
946
+ // type/path correlation.
947
+ const pathsListing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
948
+ const verboseListing = execSync(`tar -tvzf "${tarPath}"`, { encoding: 'utf8' });
949
+ const pathLines = pathsListing.split('\n').filter(Boolean);
950
+ const verboseLines = verboseListing.split('\n').filter(Boolean);
951
+ const badPaths = [];
952
+ const badTypes = [];
953
+ if (pathLines.length !== verboseLines.length) {
954
+ throw new Error(`Tar listing inconsistency: ${pathLines.length} path lines vs ${verboseLines.length} verbose lines`);
955
+ }
956
+ for (let i = 0; i < pathLines.length; i++) {
957
+ const entryPath = pathLines[i];
958
+ const typeChar = verboseLines[i].charAt(0);
959
+ if (!entryPath) continue;
960
+ // Reject by type: anything except regular file (-), directory (d).
961
+ // Long-link metadata uses 'L' or 'K'; tar emits these followed by
962
+ // another entry — we'd reject the link metadata here too which is fine
963
+ // because we don't ship long names.
964
+ if (typeChar !== '-' && typeChar !== 'd') {
965
+ badTypes.push(`${typeChar} ${entryPath}`);
966
+ continue;
967
+ }
968
+ if (entryPath.startsWith('/') || entryPath.includes('..')) {
969
+ badPaths.push(entryPath);
970
+ }
971
+ }
972
+ if (badTypes.length > 0) {
973
+ fs.unlinkSync(tarPath);
974
+ throw new Error('Payload contains non-regular entries (symlinks/hardlinks/devices): ' + badTypes.slice(0, 5).join(', '));
975
+ }
869
976
  if (badPaths.length > 0) {
870
977
  fs.unlinkSync(tarPath);
871
978
  throw new Error('Payload contains unsafe paths: ' + badPaths.join(', '));
@@ -1605,14 +1712,25 @@ async function runUninstall(vaultPath) {
1605
1712
  const rulesExist = rulesPaths.some(p => fs.existsSync(p.path));
1606
1713
  if (rulesExist) categories.push({ id: "rules", name: "Rules", description: "Global rules files for all agents", items: rulesPaths });
1607
1714
 
1608
- // Skills
1715
+ // Skills — v4.7.6: cover all agents Mover OS installs into. v4.7.5 only
1716
+ // listed 5 of ~10 paths, leaving orphaned skills behind on uninstall for
1717
+ // Gemini CLI, Cline, Roo Code, Aider, OpenCode, Continue, and the shared
1718
+ // ~/.agents/skills/ pool. Derived from AGENT_REGISTRY, but kept literal
1719
+ // here because uninstall runs after the registry may have changed in
1720
+ // newer bundles. Drift-detection lives in v4.8.0 which moves to a
1721
+ // collectInstallPaths(kind) helper.
1609
1722
  const skillsPaths = [
1610
1723
  { label: "Claude Code skills", path: path.join(home, ".claude", "skills"), dir: true, keepBuiltins: true },
1611
1724
  { label: "Cursor skills", path: path.join(home, ".cursor", "skills"), dir: true },
1612
1725
  { label: "Codex skills", path: path.join(home, ".codex", "skills"), dir: true },
1613
- { label: "Windsurf skills", path: path.join(home, ".windsurf", "skills"), dir: true },
1726
+ { label: "Gemini CLI skills", path: path.join(home, ".gemini", "skills"), dir: true },
1614
1727
  { label: "Antigravity skills", path: path.join(home, ".gemini", "antigravity", "skills"), dir: true },
1615
- ];
1728
+ { label: "Windsurf skills (legacy)", path: path.join(home, ".windsurf", "skills"), dir: true },
1729
+ { label: "Windsurf skills", path: path.join(home, ".codeium", "windsurf", "skills"), dir: true },
1730
+ { label: "Cline skills", path: path.join(home, ".cline", "skills"), dir: true },
1731
+ { label: "Roo Code skills (vault-relative)", path: vaultPath && path.join(vaultPath, ".roo", "skills"), dir: true },
1732
+ { label: "Cross-agent shared skills", path: path.join(home, ".agents", "skills"), dir: true },
1733
+ ].filter(p => p.path);
1616
1734
  const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
1617
1735
  if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
1618
1736
 
@@ -1754,7 +1872,15 @@ async function runUninstall(vaultPath) {
1754
1872
  barLn(dim("Deactivating license..."));
1755
1873
  try {
1756
1874
  const https = require("https");
1757
- const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label: os.hostname() });
1875
+ // v4.7.6: match activateKey's label scheme (machine_id) so deactivate
1876
+ // actually frees the activation slot. v4.7.5 deactivated by hostname,
1877
+ // which only worked if the user hadn't renamed their machine since
1878
+ // install. Fallback: if machine_id read fails, try hostname (covers
1879
+ // pre-v4.7.3 activations that stored hostname).
1880
+ const moverDir = path.join(os.homedir(), ".mover");
1881
+ let label = os.hostname();
1882
+ try { label = getMachineId(moverDir) || label; } catch {}
1883
+ const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label });
1758
1884
  await new Promise((resolve, reject) => {
1759
1885
  const req = https.request({
1760
1886
  hostname: "api.polar.sh",
@@ -2137,7 +2263,7 @@ const SKILL_CATEGORIES = {
2137
2263
  "json-canvas": "obsidian",
2138
2264
  // Tools (always installed — core utilities)
2139
2265
  "defuddle": "tools",
2140
- "skill-creator": "tools",
2266
+ "mover-skill-creator": "tools",
2141
2267
  "find-skills": "tools",
2142
2268
  };
2143
2269
 
@@ -2151,19 +2277,32 @@ const CATEGORY_META = [
2151
2277
  { id: "obsidian", name: "Obsidian", desc: "markdown, bases, canvas, CLI" },
2152
2278
  ];
2153
2279
 
2280
+ // Dev/eval artifacts that ship in src/skills/ but must NEVER be installed as
2281
+ // runtime skills. Matched as a directory name suffix so e.g.
2282
+ // `friction-enforcer-workspace/iteration-1/skill-snapshot/SKILL.md` (an evaluation
2283
+ // snapshot of friction-enforcer) does not get installed as a separate skill named
2284
+ // `skill-snapshot`. Caused 1+ duplicate skill in v4.7.5 installs which compounded
2285
+ // the skill-description budget pressure that drops descriptions at runtime.
2286
+ const SKILL_DEV_DIR_SUFFIXES = ["-workspace", "-benchmark", "-sandbox"];
2287
+
2288
+ function isDevSkillDir(name) {
2289
+ return SKILL_DEV_DIR_SUFFIXES.some((s) => name.endsWith(s));
2290
+ }
2291
+
2154
2292
  function findSkills(bundleDir) {
2155
2293
  const skillsDir = path.join(bundleDir, "src", "skills");
2156
2294
  if (!fs.existsSync(skillsDir)) return [];
2157
2295
  const skills = [];
2158
2296
  const walk = (dir) => {
2159
2297
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2298
+ if (!entry.isDirectory()) continue;
2299
+ // Skip dev/eval artifact roots and anything inside them.
2300
+ if (isDevSkillDir(entry.name)) continue;
2160
2301
  const full = path.join(dir, entry.name);
2161
- if (entry.isDirectory()) {
2162
- if (fs.existsSync(path.join(full, "SKILL.md"))) {
2163
- skills.push({ name: entry.name, path: full, category: SKILL_CATEGORIES[entry.name] || "tools" });
2164
- } else {
2165
- walk(full);
2166
- }
2302
+ if (fs.existsSync(path.join(full, "SKILL.md"))) {
2303
+ skills.push({ name: entry.name, path: full, category: SKILL_CATEGORIES[entry.name] || "tools" });
2304
+ } else {
2305
+ walk(full);
2167
2306
  }
2168
2307
  }
2169
2308
  };
@@ -2264,9 +2403,15 @@ function generateCodexHooks() {
2264
2403
  matcher: "startup|resume|clear",
2265
2404
  hooks: [
2266
2405
  {
2406
+ // v4.7.6: switched from "full" to "resume" mode. The full primer
2407
+ // is ~12K chars which mover-hook-adapter.js wraps as
2408
+ // additionalContext for Codex, eating the skill-description
2409
+ // budget. Codex sessions only need lightweight context refresh.
2410
+ // Timeout 15s (was 5s): session-start.sh:122 calls `npm view`
2411
+ // on cold cache; 5s killed cold-start under Codex.
2267
2412
  type: "command",
2268
- command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" full`,
2269
- timeout: 5,
2413
+ command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" resume`,
2414
+ timeout: 15,
2270
2415
  },
2271
2416
  ],
2272
2417
  },
@@ -2341,10 +2486,13 @@ function generateGeminiHooks() {
2341
2486
  matcher: "startup",
2342
2487
  hooks: [
2343
2488
  {
2489
+ // v4.7.6: same fix as the Codex hook — switched from "full" to
2490
+ // "resume" to keep mover-hook-adapter additionalContext within
2491
+ // budget. Timeout raised to 15s for cold-cache npm view fallback.
2344
2492
  name: "mover-session-start",
2345
2493
  type: "command",
2346
- command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh" full`,
2347
- timeout: 5000,
2494
+ command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh" resume`,
2495
+ timeout: 15000,
2348
2496
  },
2349
2497
  ],
2350
2498
  },
@@ -3029,6 +3177,20 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
3029
3177
  // Skip unchanged skills
3030
3178
  const sourceHash = computeSkillHash(skill.path);
3031
3179
  if (manifest.skills[skill.name]?.hash === sourceHash && fs.existsSync(dest)) {
3180
+ // v4.7.6 (post-audit): even when skipping, ensure the .mover-installed
3181
+ // stamp exists. Pre-v4.7.6 installs lack the stamp; without this
3182
+ // backfill, the manifest+stamp logic in the orphan cleanup wouldn't
3183
+ // see them as Mover-owned on the first v4.7.6 update if the manifest
3184
+ // entry was lost (rare but possible).
3185
+ try {
3186
+ const stampPath = path.join(dest, ".mover-installed");
3187
+ if (!fs.existsSync(stampPath)) {
3188
+ fs.writeFileSync(
3189
+ stampPath,
3190
+ JSON.stringify({ name: skill.name, version: "v4.7.6", at: new Date().toISOString(), backfilled: true }, null, 2)
3191
+ );
3192
+ }
3193
+ } catch {}
3032
3194
  installedNames.add(skill.name);
3033
3195
  skipped++;
3034
3196
  continue;
@@ -3036,23 +3198,40 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
3036
3198
 
3037
3199
  if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
3038
3200
  copyDirRecursive(skill.path, dest);
3201
+ // v4.7.6: stamp file proves Mover OS owns this skill. Used by orphan
3202
+ // cleanup below — only stamped skills are deletable on update. User-
3203
+ // created skills (e.g., a skill the user wrote and dropped into the
3204
+ // skills dir) survive even if their SKILL.md happens to contain
3205
+ // "## Activation" or "## When to Use" headings.
3206
+ try {
3207
+ fs.writeFileSync(
3208
+ path.join(dest, ".mover-installed"),
3209
+ JSON.stringify({ name: skill.name, version: "v4.7.6", at: new Date().toISOString() }, null, 2)
3210
+ );
3211
+ } catch {}
3039
3212
  manifest.skills[skill.name] = { hash: sourceHash, installedAt: new Date().toISOString() };
3040
3213
  installedNames.add(skill.name);
3041
3214
  count++;
3042
3215
  }
3043
3216
 
3044
- // Clean orphaned skills (renamed/removed in updates)
3045
- // Only removes dirs that contain SKILL.md preserves user-created skills
3217
+ // Clean orphaned skills (renamed/removed in updates).
3218
+ // v4.7.6: ONLY remove skills that have a `.mover-installed` stamp from a
3219
+ // prior install OR appear in the manifest. User-created skills (no stamp,
3220
+ // not in manifest) are preserved regardless of their SKILL.md contents.
3221
+ // Pre-v4.7.6 installs lack the stamp; the manifest entry covers those
3222
+ // (manifest is written for every prior install). After one cycle of v4.7.6
3223
+ // install/update, every shipped skill has both stamp and manifest entry.
3046
3224
  for (const dir of fs.readdirSync(destDir)) {
3047
3225
  if (installedNames.has(dir)) continue;
3048
3226
  const dirPath = path.join(destDir, dir);
3049
3227
  try {
3050
- if (fs.statSync(dirPath).isDirectory() && fs.existsSync(path.join(dirPath, "SKILL.md"))) {
3051
- const content = fs.readFileSync(path.join(dirPath, "SKILL.md"), "utf8");
3052
- if (content.includes("## Activation") || content.includes("## When to Use")) {
3053
- fs.rmSync(dirPath, { recursive: true, force: true });
3054
- ln(` ${dim("Removed orphan skill:")} ${dir}`);
3055
- }
3228
+ if (!fs.statSync(dirPath).isDirectory()) continue;
3229
+ if (!fs.existsSync(path.join(dirPath, "SKILL.md"))) continue;
3230
+ const hasStamp = fs.existsSync(path.join(dirPath, ".mover-installed"));
3231
+ const inManifest = Boolean(manifest.skills[dir]);
3232
+ if (hasStamp || inManifest) {
3233
+ fs.rmSync(dirPath, { recursive: true, force: true });
3234
+ ln(` ${dim("Removed orphan skill:")} ${dir}`);
3056
3235
  }
3057
3236
  } catch (e) { /* skip */ }
3058
3237
  }
@@ -3105,10 +3284,15 @@ function installHooksForClaude(bundleDir, vaultPath) {
3105
3284
  if (!existing.hooks[event]) {
3106
3285
  existing.hooks[event] = entries;
3107
3286
  } else {
3108
- // Check if our hooks are already registered (by command substring)
3287
+ // Check if our hooks are already registered (by command substring).
3288
+ // v4.7.6: split on both / and \\ so the basename extraction works on
3289
+ // Windows where command paths use backslashes. The forward-slash-only
3290
+ // split returned the entire command string on Windows, which never
3291
+ // matched the substring check, leading to duplicate hooks accumulating
3292
+ // on every install.
3109
3293
  const existingCmds = JSON.stringify(existing.hooks[event]);
3110
3294
  const alreadyHas = entries[0].hooks.every(
3111
- (h) => existingCmds.includes(h.command.split("/").pop().replace('"', ""))
3295
+ (h) => existingCmds.includes(h.command.split(/[\\/]/).pop().replace('"', ""))
3112
3296
  );
3113
3297
  if (!alreadyHas) {
3114
3298
  existing.hooks[event].push(...entries);
@@ -3325,10 +3509,18 @@ function installHooksForGemini(bundleDir) {
3325
3509
  return count;
3326
3510
  }
3327
3511
 
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.
3512
+ // Per-entry hook merge.
3513
+ //
3514
+ // v4.7.5 strategy was: skip if an entry with same matcher + same exact commands
3515
+ // exists. That prevented duplicates on re-run BUT also meant existing v4.7.5
3516
+ // installs with broken commands (e.g. `session-start.sh full` instead of the
3517
+ // v4.7.6 `session-start.sh resume`) never got upgraded — the merge saw a Mover
3518
+ // entry and skipped, leaving the old broken hook in place.
3519
+ //
3520
+ // v4.7.6 strategy: identify Mover entries by command containing
3521
+ // "mover-hook-adapter.js" or "src/hooks/" path. For those, REPLACE the existing
3522
+ // entry with the new one. For non-Mover entries (user customizations), leave
3523
+ // untouched and append the new Mover entry alongside.
3332
3524
  function mergeHooksConfig(existing, newConfig) {
3333
3525
  if (!existing.hooks) existing.hooks = {};
3334
3526
  for (const [event, newEntries] of Object.entries(newConfig.hooks || {})) {
@@ -3338,15 +3530,32 @@ function mergeHooksConfig(existing, newConfig) {
3338
3530
  }
3339
3531
  for (const newEntry of newEntries) {
3340
3532
  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);
3533
+ // v4.7.6 (post-audit): we identify the matching matcher entry and
3534
+ // replace ONLY the Mover-owned commands inside it. Non-Mover commands
3535
+ // a user has added at the same matcher level are preserved. The prior
3536
+ // approach replaced the whole entry, wiping user customizations.
3537
+ const isMoverCmd = (c) =>
3538
+ typeof c === "string" &&
3539
+ (c.includes("mover-hook-adapter.js") || c.includes("src/hooks/"));
3540
+ const matcherIdx = existing.hooks[event].findIndex(
3541
+ (e) => (e.matcher || "") === (matcher || "")
3542
+ );
3543
+ if (matcherIdx < 0) {
3544
+ // No entry at this matcher — append cleanly.
3545
+ existing.hooks[event].push(newEntry);
3546
+ continue;
3547
+ }
3548
+ const matcherEntry = existing.hooks[event][matcherIdx];
3549
+ if (!Array.isArray(matcherEntry.hooks)) matcherEntry.hooks = [];
3550
+ // Drop existing Mover-owned hooks (we will re-add the new versions).
3551
+ // Keep every user-owned hook untouched.
3552
+ matcherEntry.hooks = matcherEntry.hooks.filter(
3553
+ (h) => !isMoverCmd(h && h.command)
3554
+ );
3555
+ // Append the fresh Mover hooks for this matcher.
3556
+ for (const h of newEntry.hooks || []) {
3557
+ if (isMoverCmd(h && h.command)) matcherEntry.hooks.push(h);
3558
+ }
3350
3559
  }
3351
3560
  }
3352
3561
  }
@@ -3537,9 +3746,40 @@ function installWindsurf(bundleDir, vaultPath, skillOpts) {
3537
3746
  }
3538
3747
 
3539
3748
  if (skillOpts && skillOpts.install) {
3540
- const skillsDir = path.join(home, ".windsurf", "skills");
3749
+ // v4.7.6: standardize on the registry-canonical Windsurf path
3750
+ // (~/.codeium/windsurf/skills). v4.7.5 wrote to ~/.windsurf/skills which
3751
+ // conflicted with the registry entry and meant skills were duplicated
3752
+ // across both paths on machines that had been installed pre-v4.7.5.
3753
+ const skillsDir = AGENT_REGISTRY.windsurf.skills.dest();
3754
+ const legacySkillsDir = path.join(home, ".windsurf", "skills");
3541
3755
  const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
3542
3756
  if (skCount > 0) steps.push(`${skCount} skills`);
3757
+
3758
+ // Migrate from legacy ~/.windsurf/skills.
3759
+ // v4.7.6 (post-audit): only delete legacy entries that have a
3760
+ // .mover-installed stamp file (proving Mover OS owns them). Same-name
3761
+ // user-created skills in the legacy path that lack the stamp are
3762
+ // preserved. The prior "same-name = delete" logic risked nuking
3763
+ // divergent user skills.
3764
+ if (fs.existsSync(legacySkillsDir) && legacySkillsDir !== skillsDir) {
3765
+ try {
3766
+ for (const entry of fs.readdirSync(legacySkillsDir)) {
3767
+ const legacyEntry = path.join(legacySkillsDir, entry);
3768
+ const canonicalEntry = path.join(skillsDir, entry);
3769
+ if (!fs.statSync(legacyEntry).isDirectory()) continue;
3770
+ if (!fs.existsSync(canonicalEntry)) continue;
3771
+ // Require a .mover-installed stamp at the legacy path for delete.
3772
+ const legacyStamp = path.join(legacyEntry, ".mover-installed");
3773
+ if (fs.existsSync(legacyStamp)) {
3774
+ fs.rmSync(legacyEntry, { recursive: true, force: true });
3775
+ }
3776
+ }
3777
+ // Remove empty legacy parent dir.
3778
+ try {
3779
+ if (fs.readdirSync(legacySkillsDir).length === 0) fs.rmdirSync(legacySkillsDir);
3780
+ } catch {}
3781
+ } catch {}
3782
+ }
3543
3783
  }
3544
3784
 
3545
3785
  return steps;
@@ -3567,8 +3807,40 @@ function installGeminiCli(bundleDir, vaultPath, skillOpts, writtenFiles) {
3567
3807
  if (wfCount > 0) steps.push(`${wfCount} commands`);
3568
3808
 
3569
3809
  if (skillOpts && skillOpts.install) {
3570
- const skCount = installSkillPacks(bundleDir, path.join(geminiDir, "skills"), skillOpts.categories);
3810
+ const geminiSkillsDir = path.join(geminiDir, "skills");
3811
+ const skCount = installSkillPacks(bundleDir, geminiSkillsDir, skillOpts.categories);
3571
3812
  if (skCount > 0) steps.push(`${skCount} skills`);
3813
+
3814
+ // v4.7.6: clean up duplicate Mover skills in ~/.agents/skills/.
3815
+ //
3816
+ // Gemini CLI scans both ~/.gemini/skills/ (canonical) AND
3817
+ // ~/.agents/skills/ (cross-agent shared pool) at session start. If a Mover
3818
+ // install has populated both, Gemini emits a wall of "Skill conflict
3819
+ // detected: ... is overriding ..." warnings. Worse, every shipped skill
3820
+ // takes a budget slot twice in the agent's effective skill manifest.
3821
+ //
3822
+ // Fix: after writing to the canonical path, scan ~/.agents/skills/ for
3823
+ // entries that we just installed (same name as a directory we own at the
3824
+ // canonical path) and remove only those. User skills in ~/.agents/skills/
3825
+ // (Amp/etc.) stay untouched.
3826
+ // v4.7.6 (post-audit): require a `.mover-installed` stamp on the shared
3827
+ // entry before deleting. Prior "same-name = delete" risked nuking
3828
+ // user-created divergent skills with the same folder name.
3829
+ const sharedSkillsDir = path.join(home, ".agents", "skills");
3830
+ if (fs.existsSync(sharedSkillsDir) && sharedSkillsDir !== geminiSkillsDir) {
3831
+ try {
3832
+ for (const entry of fs.readdirSync(sharedSkillsDir)) {
3833
+ const sharedEntry = path.join(sharedSkillsDir, entry);
3834
+ const canonicalEntry = path.join(geminiSkillsDir, entry);
3835
+ if (!fs.statSync(sharedEntry).isDirectory()) continue;
3836
+ if (!fs.existsSync(canonicalEntry)) continue;
3837
+ const sharedStamp = path.join(sharedEntry, ".mover-installed");
3838
+ if (fs.existsSync(sharedStamp)) {
3839
+ fs.rmSync(sharedEntry, { recursive: true, force: true });
3840
+ }
3841
+ }
3842
+ } catch {}
3843
+ }
3572
3844
  }
3573
3845
 
3574
3846
  // v4.7.5: Native Gemini hook support via mover-hook-adapter.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mover-os",
3
- "version": "4.7.5",
3
+ "version": "4.7.6",
4
4
  "description": "Your AI co-founder. Remembers your goals, pushes back when you drift. Works with 15 AI agents.",
5
5
  "bin": {
6
6
  "moveros": "install.js"