mover-os 4.5.2 → 4.5.4

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 +271 -289
  2. package/package.json +1 -1
package/install.js CHANGED
@@ -92,17 +92,10 @@ function waveGradient(text, frame, totalFrames) {
92
92
  // ─── TUI: Alternate screen buffer ────────────────────────────────────────────
93
93
  let _inAltScreen = false;
94
94
  function enterAltScreen() {
95
- if (!IS_TTY || _inAltScreen) return;
96
- w("\x1b[?1049h"); // Enter alternate screen
97
- w("\x1b[2J\x1b[H"); // Clear + cursor home
98
- w(S.hide);
99
- _inAltScreen = true;
95
+ // Disabled: keep output in terminal scrollback history
100
96
  }
101
97
  function exitAltScreen() {
102
- if (!IS_TTY || !_inAltScreen) return;
103
- w("\x1b[?1049l"); // Restore original screen
104
- w(S.show);
105
- _inAltScreen = false;
98
+ // Disabled: no alt screen to exit
106
99
  }
107
100
  function clearContent() {
108
101
  w("\x1b[2J\x1b[H"); // Clear screen + cursor to top
@@ -723,9 +716,14 @@ const DOWNLOAD_URL = "https://moveros.dev/api/download";
723
716
 
724
717
  async function downloadPayload(key) {
725
718
  const https = require("https");
726
- const tmpDir = path.join(os.tmpdir(), `moveros-${Date.now()}`);
727
- fs.mkdirSync(tmpDir, { recursive: true });
728
- const tarPath = path.join(tmpDir, "payload.tar.gz");
719
+ const moverDir = path.join(os.homedir(), ".mover");
720
+ fs.mkdirSync(moverDir, { recursive: true, mode: 0o700 });
721
+ // Clean old staged src/ before extracting fresh
722
+ const stagedSrc = path.join(moverDir, "src");
723
+ if (fs.existsSync(stagedSrc)) {
724
+ fs.rmSync(stagedSrc, { recursive: true, force: true });
725
+ }
726
+ const tarPath = path.join(moverDir, "payload.tar.gz");
729
727
 
730
728
  // Download tarball
731
729
  await new Promise((resolve, reject) => {
@@ -785,9 +783,9 @@ async function downloadPayload(key) {
785
783
  req.end();
786
784
  });
787
785
 
788
- // Extract tarball
786
+ // Extract tarball into ~/.mover/ (produces ~/.mover/src/)
789
787
  try {
790
- execSync(`tar -xzf "${tarPath}" -C "${tmpDir}"`, { stdio: "ignore" });
788
+ execSync(`tar -xzf "${tarPath}" -C "${moverDir}"`, { stdio: "ignore" });
791
789
  } catch {
792
790
  throw new Error("Failed to extract payload. Ensure 'tar' is available.");
793
791
  }
@@ -795,7 +793,7 @@ async function downloadPayload(key) {
795
793
  // Clean up tarball
796
794
  try { fs.unlinkSync(tarPath); } catch {}
797
795
 
798
- return tmpDir;
796
+ return moverDir;
799
797
  }
800
798
 
801
799
  // ─── Path helpers ────────────────────────────────────────────────────────────
@@ -956,12 +954,15 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
956
954
  const home = os.homedir();
957
955
  const result = { workflows: [], hooks: [], rules: null, templates: [], skills: [], statusline: "unchanged" };
958
956
 
957
+ // Expand selection IDs to target IDs (e.g. "gemini-cli" → includes "antigravity")
958
+ const targetIds = expandTargetIds(selectedAgentIds);
959
+
959
960
  // --- Workflows: compare source vs first installed destination ---
960
961
  const wfSrc = path.join(bundleDir, "src", "workflows");
961
962
  const wfDests = [
962
- selectedAgentIds.includes("claude-code") && path.join(home, ".claude", "commands"),
963
- selectedAgentIds.includes("cursor") && path.join(home, ".cursor", "commands"),
964
- selectedAgentIds.includes("antigravity") && path.join(home, ".gemini", "antigravity", "global_workflows"),
963
+ targetIds.includes("claude-code") && path.join(home, ".claude", "commands"),
964
+ targetIds.includes("cursor") && path.join(home, ".cursor", "commands"),
965
+ targetIds.includes("antigravity") && path.join(home, ".gemini", "antigravity", "global_workflows"),
965
966
  ].filter(Boolean);
966
967
  const wfDest = wfDests.find((d) => fs.existsSync(d));
967
968
 
@@ -984,7 +985,7 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
984
985
  // --- Hooks: compare with CRLF normalization ---
985
986
  const hooksSrc = path.join(bundleDir, "src", "hooks");
986
987
  const hooksDest = path.join(home, ".claude", "hooks");
987
- if (fs.existsSync(hooksSrc) && selectedAgentIds.includes("claude-code")) {
988
+ if (fs.existsSync(hooksSrc) && targetIds.includes("claude-code")) {
988
989
  for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".sh") || f.endsWith(".md"))) {
989
990
  const srcContent = fs.readFileSync(path.join(hooksSrc, file), "utf8")
990
991
  .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -1005,9 +1006,9 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
1005
1006
  // --- Rules: compare source vs first installed destination ---
1006
1007
  const rulesSrc = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1007
1008
  const rulesDests = [
1008
- selectedAgentIds.includes("claude-code") && path.join(home, ".claude", "CLAUDE.md"),
1009
- selectedAgentIds.includes("cursor") && path.join(home, ".cursor", "rules", "mover-os.mdc"),
1010
- selectedAgentIds.includes("gemini-cli") && path.join(home, ".gemini", "GEMINI.md"),
1009
+ targetIds.includes("claude-code") && path.join(home, ".claude", "CLAUDE.md"),
1010
+ targetIds.includes("cursor") && path.join(home, ".cursor", "rules", "mover-os.mdc"),
1011
+ targetIds.includes("gemini-cli") && path.join(home, ".gemini", "GEMINI.md"),
1011
1012
  ].filter(Boolean);
1012
1013
  const rulesDest = rulesDests.find((d) => fs.existsSync(d));
1013
1014
  if (fs.existsSync(rulesSrc) && rulesDest) {
@@ -1049,9 +1050,9 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
1049
1050
  // --- Skills: compare source vs installed ---
1050
1051
  const skillsSrc = path.join(bundleDir, "src", "skills");
1051
1052
  const skillsDests = [
1052
- selectedAgentIds.includes("claude-code") && path.join(home, ".claude", "skills"),
1053
- selectedAgentIds.includes("cursor") && path.join(home, ".cursor", "skills"),
1054
- selectedAgentIds.includes("cline") && path.join(home, ".cline", "skills"),
1053
+ targetIds.includes("claude-code") && path.join(home, ".claude", "skills"),
1054
+ targetIds.includes("cursor") && path.join(home, ".cursor", "skills"),
1055
+ targetIds.includes("cline") && path.join(home, ".cline", "skills"),
1055
1056
  ].filter(Boolean);
1056
1057
  const skillsDest = skillsDests.find((d) => fs.existsSync(d));
1057
1058
  if (fs.existsSync(skillsSrc) && skillsDest) {
@@ -1081,17 +1082,94 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
1081
1082
  // --- Statusline ---
1082
1083
  const slSrc = path.join(bundleDir, "src", "hooks", "statusline.js");
1083
1084
  const slDest = path.join(home, ".claude", "statusline.js");
1084
- if (fs.existsSync(slSrc) && fs.existsSync(slDest) && selectedAgentIds.includes("claude-code")) {
1085
+ if (fs.existsSync(slSrc) && fs.existsSync(slDest) && targetIds.includes("claude-code")) {
1085
1086
  const srcSl = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
1086
1087
  const destSl = fs.readFileSync(slDest, "utf8").replace(/\r\n/g, "\n");
1087
1088
  result.statusline = srcSl === destSl ? "unchanged" : "changed";
1088
- } else if (fs.existsSync(slSrc) && !fs.existsSync(slDest) && selectedAgentIds.includes("claude-code")) {
1089
+ } else if (fs.existsSync(slSrc) && !fs.existsSync(slDest) && targetIds.includes("claude-code")) {
1089
1090
  result.statusline = "new";
1090
1091
  }
1091
1092
 
1092
1093
  return result;
1093
1094
  }
1094
1095
 
1096
+ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1097
+ const home = os.homedir();
1098
+ const targetIds = expandTargetIds(selectedAgentIds);
1099
+ const now = new Date();
1100
+ const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
1101
+ const backupRoot = path.join(home, ".mover", "backups", `pre-update-${ts}`);
1102
+ let backed = 0;
1103
+
1104
+ const backup = (src, relPath) => {
1105
+ if (!fs.existsSync(src)) return;
1106
+ const dest = path.join(backupRoot, relPath);
1107
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1108
+ try { fs.copyFileSync(src, dest); backed++; } catch {}
1109
+ };
1110
+
1111
+ // Workflows
1112
+ const wfDests = [
1113
+ targetIds.includes("claude-code") && path.join(home, ".claude", "commands"),
1114
+ targetIds.includes("cursor") && path.join(home, ".cursor", "commands"),
1115
+ targetIds.includes("antigravity") && path.join(home, ".gemini", "antigravity", "global_workflows"),
1116
+ ].filter(Boolean);
1117
+ for (const f of changes.workflows.filter((x) => x.status === "changed")) {
1118
+ for (const dir of wfDests) {
1119
+ backup(path.join(dir, f.file), path.join("workflows", path.basename(dir), f.file));
1120
+ }
1121
+ }
1122
+
1123
+ // Hooks
1124
+ if (targetIds.includes("claude-code")) {
1125
+ for (const f of changes.hooks.filter((x) => x.status === "changed")) {
1126
+ backup(path.join(home, ".claude", "hooks", f.file), path.join("hooks", f.file));
1127
+ }
1128
+ }
1129
+
1130
+ // Rules
1131
+ if (changes.rules === "changed") {
1132
+ const rulesDests = [
1133
+ targetIds.includes("claude-code") && path.join(home, ".claude", "CLAUDE.md"),
1134
+ targetIds.includes("gemini-cli") && path.join(home, ".gemini", "GEMINI.md"),
1135
+ targetIds.includes("cursor") && path.join(home, ".cursor", "rules", "mover-os.mdc"),
1136
+ ].filter(Boolean);
1137
+ for (const p of rulesDests) {
1138
+ backup(p, path.join("rules", path.basename(p)));
1139
+ }
1140
+ }
1141
+
1142
+ // Skills
1143
+ const skillsDests = [
1144
+ targetIds.includes("claude-code") && path.join(home, ".claude", "skills"),
1145
+ targetIds.includes("cursor") && path.join(home, ".cursor", "skills"),
1146
+ targetIds.includes("cline") && path.join(home, ".cline", "skills"),
1147
+ ].filter(Boolean);
1148
+ for (const f of (changes.skills || []).filter((x) => x.status === "changed")) {
1149
+ for (const dir of skillsDests) {
1150
+ const skillFile = path.join(dir, f.file, "SKILL.md");
1151
+ if (fs.existsSync(skillFile)) {
1152
+ backup(skillFile, path.join("skills", f.file, path.basename(dir), "SKILL.md"));
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ // Statusline
1158
+ if (changes.statusline === "changed" && targetIds.includes("claude-code")) {
1159
+ backup(path.join(home, ".claude", "statusline.js"), "statusline.js");
1160
+ }
1161
+
1162
+ // Templates
1163
+ for (const f of changes.templates.filter((x) => x.status === "changed")) {
1164
+ backup(path.join(vaultPath, f.file), path.join("templates", f.file));
1165
+ }
1166
+
1167
+ if (backed > 0) {
1168
+ statusLine("ok", "Auto-backup", `${backed} files → ${dim(backupRoot)}`);
1169
+ }
1170
+ return backed;
1171
+ }
1172
+
1095
1173
  function countChanges(changes) {
1096
1174
  let n = 0;
1097
1175
  n += changes.workflows.filter((f) => f.status !== "unchanged").length;
@@ -1627,6 +1705,17 @@ const AGENTS = AGENT_SELECTIONS.map((s) => ({
1627
1705
  detect: AGENT_REGISTRY[s.targets[0]].detect,
1628
1706
  }));
1629
1707
 
1708
+ // Expand selection IDs to install target IDs (e.g. "gemini-cli" → ["gemini-cli", "antigravity"])
1709
+ function expandTargetIds(selectionIds) {
1710
+ const result = [];
1711
+ for (const id of selectionIds) {
1712
+ const sel = AGENT_SELECTIONS.find((s) => s.id === id);
1713
+ if (sel) { for (const t of sel.targets) result.push(t); }
1714
+ else result.push(id);
1715
+ }
1716
+ return [...new Set(result)];
1717
+ }
1718
+
1630
1719
  // ─── Utility functions ──────────────────────────────────────────────────────
1631
1720
  function cmdExists(cmd) {
1632
1721
  try {
@@ -1991,19 +2080,19 @@ async function fetchPrayerTimes(city, country) {
1991
2080
  const https = require("https");
1992
2081
  const year = new Date().getFullYear();
1993
2082
  const allTimes = {};
2083
+ let failedMonths = 0;
1994
2084
 
1995
2085
  for (let month = 1; month <= 12; month++) {
1996
2086
  try {
1997
2087
  const url = `https://api.aladhan.com/v1/calendarByCity/${year}/${month}?city=${encodeURIComponent(city)}&country=${encodeURIComponent(country)}&method=15`;
1998
2088
  const body = await new Promise((resolve, reject) => {
1999
- const req = https.request(url, { method: "GET", timeout: 10000 }, (res) => {
2089
+ const req = https.get(url, { timeout: 10000 }, (res) => {
2000
2090
  let data = "";
2001
2091
  res.on("data", (c) => (data += c));
2002
2092
  res.on("end", () => resolve(data));
2003
2093
  });
2004
2094
  req.on("error", reject);
2005
2095
  req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
2006
- req.end();
2007
2096
  });
2008
2097
 
2009
2098
  const json = JSON.parse(body);
@@ -2020,9 +2109,11 @@ async function fetchPrayerTimes(city, country) {
2020
2109
  isha: t.Isha.replace(/\s*\(.*\)/, ""),
2021
2110
  };
2022
2111
  }
2112
+ } else {
2113
+ failedMonths++;
2023
2114
  }
2024
2115
  } catch {
2025
- // Skip failed months — partial data is still useful
2116
+ failedMonths++;
2026
2117
  }
2027
2118
  }
2028
2119
 
@@ -2113,6 +2204,8 @@ function writeMoverConfig(vaultPath, agentIds, licenseKey, opts = {}) {
2113
2204
  if (existing.settings) config.settings = { ...existing.settings };
2114
2205
  // Preserve prayer_times fallback
2115
2206
  if (existing.prayer_times) config.prayer_times = existing.prayer_times;
2207
+ // Preserve frecency data
2208
+ if (existing.cli_usage) config.cli_usage = existing.cli_usage;
2116
2209
  config.updatedAt = new Date().toISOString();
2117
2210
  } catch {}
2118
2211
  }
@@ -3031,7 +3124,8 @@ async function cmdDoctor(opts) {
3031
3124
  // Per-agent checks
3032
3125
  barLn();
3033
3126
  barLn(dim(" Agents:"));
3034
- const cfgData = fs.existsSync(cfg) ? JSON.parse(fs.readFileSync(cfg, "utf8")) : {};
3127
+ let cfgData = {};
3128
+ if (fs.existsSync(cfg)) { try { cfgData = JSON.parse(fs.readFileSync(cfg, "utf8")); } catch { statusLine("warn", "Config", "corrupt JSON"); } }
3035
3129
  const installedAgents = cfgData.agents || [];
3036
3130
  if (installedAgents.length === 0) {
3037
3131
  barLn(dim(" No agents recorded in config."));
@@ -3057,7 +3151,7 @@ async function cmdDoctor(opts) {
3057
3151
  const hasCmds = fs.existsSync(cp) && fs.readdirSync(cp).length > 0;
3058
3152
  checks.push(hasCmds ? "commands" : dim("no commands"));
3059
3153
  }
3060
- const allOk = checks.every((c) => !c.includes("missing"));
3154
+ const allOk = checks.every((c) => c === strip(c)); // styled text = problem
3061
3155
  statusLine(allOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
3062
3156
  }
3063
3157
 
@@ -3102,7 +3196,7 @@ async function cmdPulse(opts) {
3102
3196
  const focusMatch = ac.match(/\*\*Focus:\*\*\s*(.+)/i) || ac.match(/Single Test:\s*(.+)/i);
3103
3197
  if (focusMatch) focus = focusMatch[1].trim();
3104
3198
  // Blockers
3105
- const blockerSection = ac.match(/##.*Blocker[s]?[\s\S]*?(?=\n##|\n---|\Z)/i);
3199
+ const blockerSection = ac.match(/##.*Blocker[s]?[\s\S]*?(?=\n##|\n---|$)/i);
3106
3200
  if (blockerSection) {
3107
3201
  const lines = blockerSection[0].split("\n").filter((l) => l.trim().startsWith("-") || l.trim().startsWith("*"));
3108
3202
  blockers = lines.map((l) => l.replace(/^[\s*-]+/, "").trim()).filter(Boolean).slice(0, 3);
@@ -3491,7 +3585,7 @@ async function cmdReplay(opts) {
3491
3585
  const daily = fs.readFileSync(dailyPath, "utf8");
3492
3586
 
3493
3587
  // Extract session log entries
3494
- const logMatch = daily.match(/##\s*Session Log[\s\S]*?(?=\n## [^#]|\Z)/i);
3588
+ const logMatch = daily.match(/##\s*Session Log[\s\S]*?(?=\n## [^#]|$)/i);
3495
3589
  if (!logMatch) {
3496
3590
  barLn(dim(" No session log found in this Daily Note."));
3497
3591
  return;
@@ -3766,27 +3860,29 @@ async function cmdPrayer(opts) {
3766
3860
  barLn();
3767
3861
  const city = await textInput({ label: "City", placeholder: "London" });
3768
3862
  if (city === null) return;
3863
+ if (!city.trim()) { barLn(yellow(" City cannot be empty.")); return; }
3769
3864
  const country = await textInput({ label: "Country", placeholder: "United Kingdom" });
3770
3865
  if (country === null) return;
3866
+ if (!country.trim()) { barLn(yellow(" Country cannot be empty.")); return; }
3771
3867
  barLn();
3772
3868
 
3773
- if (city && country) {
3774
- const sp = spinner("Fetching prayer times (12 months)");
3775
- const result = await fetchPrayerTimes(city.trim(), country.trim());
3776
- if (result && Object.keys(result.times).length > 0) {
3777
- fs.writeFileSync(ttPath, JSON.stringify(result, null, 2), "utf8");
3778
- sp.stop(`Saved ${Object.keys(result.times).length} days`);
3779
- barLn(dim(" These are calculated adhan times, not mosque jama'ah times."));
3780
- barLn(dim(" For your mosque's specific times, choose 'Paste mosque timetable'."));
3781
- } else {
3782
- sp.stop(yellow("Could not fetch. Check city/country spelling."));
3783
- }
3869
+ const sp = spinner("Fetching prayer times (12 months)");
3870
+ const result = await fetchPrayerTimes(city.trim(), country.trim());
3871
+ if (result && Object.keys(result.times).length > 0) {
3872
+ fs.writeFileSync(ttPath, JSON.stringify(result, null, 2), "utf8");
3873
+ sp.stop(`Saved ${Object.keys(result.times).length} days`);
3874
+ barLn(dim(" These are calculated adhan times, not mosque jama'ah times."));
3875
+ barLn(dim(" For your mosque's specific times, choose 'Paste mosque timetable'."));
3876
+
3877
+ // Auto-enable
3878
+ if (!cfg.settings) cfg.settings = {};
3879
+ cfg.settings.show_prayer_times = true;
3880
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), "utf8");
3881
+ } else {
3882
+ sp.stop(yellow("Could not fetch prayer times."));
3883
+ barLn(yellow(" Check the city/country spelling and try again."));
3884
+ barLn(dim(" Example: City = London, Country = United Kingdom"));
3784
3885
  }
3785
-
3786
- // Auto-enable
3787
- if (!cfg.settings) cfg.settings = {};
3788
- cfg.settings.show_prayer_times = true;
3789
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), "utf8");
3790
3886
  } else if (choice === "paste") {
3791
3887
  barLn();
3792
3888
  barLn(dim(" Paste your mosque's timetable below."));
@@ -4046,10 +4142,11 @@ async function cmdRestore(opts) {
4046
4142
  const archivesDir = path.join(vault, "04_Archives");
4047
4143
  if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
4048
4144
 
4145
+ const backupPrefixes = ["Backup_", "Engine_Backup_", "Areas_Backup_", "Agent_Backup_"];
4049
4146
  const backups = fs.readdirSync(archivesDir)
4050
4147
  .filter((d) => {
4051
4148
  const full = path.join(archivesDir, d);
4052
- return fs.statSync(full).isDirectory() && (d.startsWith("Backup_") || d.startsWith("Engine_Backup_"));
4149
+ return fs.statSync(full).isDirectory() && backupPrefixes.some((p) => d.startsWith(p));
4053
4150
  })
4054
4151
  .sort()
4055
4152
  .reverse();
@@ -4077,25 +4174,67 @@ async function cmdRestore(opts) {
4077
4174
 
4078
4175
  const backupPath = path.join(archivesDir, selected);
4079
4176
 
4080
- // Check for engine dir in backup
4081
- const engPath = path.join(backupPath, "engine");
4082
- if (fs.existsSync(engPath)) {
4083
- const engineDir = path.join(vault, "02_Areas", "Engine");
4084
- let restored = 0;
4085
- for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
4086
- fs.copyFileSync(path.join(engPath, f), path.join(engineDir, f));
4087
- restored++;
4177
+ let totalRestored = 0;
4178
+
4179
+ if (selected.startsWith("Areas_Backup_")) {
4180
+ // Areas backup: restore entire 02_Areas folder
4181
+ const areasTarget = path.join(vault, "02_Areas");
4182
+ copyDirRecursive(backupPath, areasTarget);
4183
+ statusLine("ok", "Areas", "folder restored");
4184
+ totalRestored++;
4185
+ } else if (selected.startsWith("Agent_Backup_")) {
4186
+ // Agent backup: restore rules/skills per agent
4187
+ let agentCount = 0;
4188
+ for (const agDir of fs.readdirSync(backupPath).filter((d) => fs.statSync(path.join(backupPath, d)).isDirectory())) {
4189
+ const reg = AGENT_REGISTRY[agDir];
4190
+ if (!reg) continue;
4191
+ const srcDir = path.join(backupPath, agDir);
4192
+ // Restore rules file
4193
+ if (reg.rules && reg.rules.dest) {
4194
+ const rulesFile = fs.readdirSync(srcDir).find((f) => !f.startsWith("skills") && fs.statSync(path.join(srcDir, f)).isFile());
4195
+ if (rulesFile) {
4196
+ try {
4197
+ const dest = reg.rules.dest(vault);
4198
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
4199
+ fs.copyFileSync(path.join(srcDir, rulesFile), dest);
4200
+ agentCount++;
4201
+ } catch {}
4202
+ }
4203
+ }
4204
+ // Restore skills directory
4205
+ const skillsSrc = path.join(srcDir, "skills");
4206
+ if (fs.existsSync(skillsSrc) && reg.skills && reg.skills.dest) {
4207
+ try {
4208
+ copyDirRecursive(skillsSrc, reg.skills.dest(vault));
4209
+ agentCount++;
4210
+ } catch {}
4211
+ }
4088
4212
  }
4089
- statusLine("ok", "Engine", `${restored} files restored`);
4213
+ statusLine("ok", "Agents", `${agentCount} items restored`);
4214
+ totalRestored = agentCount;
4090
4215
  } else {
4091
- // Legacy backup format (files directly in backup dir)
4092
- const engineDir = path.join(vault, "02_Areas", "Engine");
4093
- let restored = 0;
4094
- for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
4095
- fs.copyFileSync(path.join(backupPath, f), path.join(engineDir, f));
4096
- restored++;
4216
+ // Engine backup (Engine_Backup_ or legacy Backup_)
4217
+ const engPath = path.join(backupPath, "engine");
4218
+ if (fs.existsSync(engPath)) {
4219
+ const engineDir = path.join(vault, "02_Areas", "Engine");
4220
+ let restored = 0;
4221
+ for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
4222
+ fs.copyFileSync(path.join(engPath, f), path.join(engineDir, f));
4223
+ restored++;
4224
+ }
4225
+ statusLine("ok", "Engine", `${restored} files restored`);
4226
+ totalRestored = restored;
4227
+ } else {
4228
+ // Legacy backup format (files directly in backup dir)
4229
+ const engineDir = path.join(vault, "02_Areas", "Engine");
4230
+ let restored = 0;
4231
+ for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
4232
+ fs.copyFileSync(path.join(backupPath, f), path.join(engineDir, f));
4233
+ restored++;
4234
+ }
4235
+ if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
4236
+ totalRestored = restored;
4097
4237
  }
4098
- if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
4099
4238
  }
4100
4239
 
4101
4240
  barLn();
@@ -4597,7 +4736,12 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4597
4736
  barLn(`${yellow("CLI update available:")} ${dim(localVer)} ${dim("\u2192")} ${green(npmVer)}`);
4598
4737
  const sp = spinner("Updating CLI");
4599
4738
  try {
4600
- execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
4739
+ try {
4740
+ execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
4741
+ } catch {
4742
+ // Retry with sudo (macOS/Linux where global npm needs root)
4743
+ execSync("sudo npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
4744
+ }
4601
4745
  sp.stop(`CLI updated to ${npmVer}`);
4602
4746
  barLn(dim(" Re-running with updated CLI..."));
4603
4747
  barLn();
@@ -4609,7 +4753,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4609
4753
  process.exit(result.status || 0);
4610
4754
  } catch (e) {
4611
4755
  sp.stop(yellow(`CLI self-update failed: ${e.message}`));
4612
- barLn(dim(" Continuing with current version..."));
4756
+ barLn(dim(" Try: sudo npm i -g mover-os"));
4613
4757
  }
4614
4758
  } else {
4615
4759
  statusLine("ok", "CLI", `up to date (${localVer})`);
@@ -4678,123 +4822,54 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4678
4822
  newVer = pkg.version || newVer;
4679
4823
  } catch {}
4680
4824
 
4681
- // Quick mode: skip interactive steps
4825
+ // ── Stage only no direct file overwrite ──
4826
+ // Files are staged at ~/.mover/src/. The /update AI workflow handles
4827
+ // the actual apply with merge support for user customizations.
4828
+
4829
+ const detectedAgents = AGENTS.filter((a) => a.detect());
4830
+ const selectedIds = detectedAgents.map((a) => a.id);
4831
+
4832
+ // Quick mode: force-apply (CI/headless — no user customizations to protect)
4682
4833
  if (isQuick) {
4683
- const detectedAgents = AGENTS.filter((a) => a.detect());
4684
4834
  if (detectedAgents.length === 0) { outro(red("No AI agents detected.")); process.exit(1); }
4685
- const selectedIds = detectedAgents.map((a) => a.id);
4686
4835
  const changes = detectChanges(bundleDir, vaultPath, selectedIds);
4687
4836
  const totalChanged = countChanges(changes);
4688
4837
  displayChangeSummary(changes, installedVer, newVer);
4689
4838
  if (totalChanged === 0) { outro(green("Already up to date.")); return; }
4839
+ autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
4690
4840
  barLn(bold("Updating..."));
4691
4841
  barLn();
4692
4842
  createVaultStructure(vaultPath);
4693
4843
  installTemplateFiles(bundleDir, vaultPath);
4694
4844
  const writtenFiles = new Set();
4695
- const skillOpts = { install: true, categories: null, workflows: null };
4845
+ const skillOpts = { install: true, categories: null, workflows: null, statusLine: changes.statusline !== "unchanged" };
4696
4846
  for (const agent of detectedAgents) {
4697
- const fn = AGENT_INSTALLERS[agent.id];
4698
- if (!fn) continue;
4699
- const sp = spinner(agent.name);
4700
- const steps = fn(bundleDir, vaultPath, skillOpts, writtenFiles, agent.id);
4701
- await sleep(200);
4702
- sp.stop(steps.length > 0 ? `${agent.name} ${dim(steps.join(", "))}` : `${agent.name} ${dim("configured")}`);
4847
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
4848
+ const targets = sel ? sel.targets : [agent.id];
4849
+ for (const targetId of targets) {
4850
+ const fn = AGENT_INSTALLERS[targetId];
4851
+ if (!fn) continue;
4852
+ const targetReg = AGENT_REGISTRY[targetId];
4853
+ const displayName = targetReg ? targetReg.name : agent.name;
4854
+ const sp = spinner(displayName);
4855
+ const steps = fn(bundleDir, vaultPath, skillOpts, writtenFiles, targetId);
4856
+ await sleep(200);
4857
+ sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
4858
+ }
4703
4859
  }
4704
4860
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
4705
4861
  writeMoverConfig(vaultPath, selectedIds);
4706
4862
  barLn();
4707
4863
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
4708
- outro(`${green("Done.")} ${totalChanged} files updated in ${elapsed}s. Run ${bold("/update")} if version bumped.`);
4864
+ outro(`${green("Done.")} ${totalChanged} files updated in ${elapsed}s.`);
4709
4865
  return;
4710
4866
  }
4711
4867
 
4868
+ // ── Interactive mode: stage + summary ──
4712
4869
  // Step 4: What's New
4713
4870
  showWhatsNew(installedVer, newVer);
4714
4871
 
4715
- // Step 5: Backup Offer
4716
- const engine = detectEngineFiles(vaultPath);
4717
- if (engine.exists) {
4718
- question("Back up before updating?");
4719
- barLn(dim(" Select what to save. Esc to skip."));
4720
- barLn();
4721
- const backupItems = [
4722
- { id: "engine", name: "Engine files", tier: "Identity, Strategy, Goals, Context" },
4723
- ];
4724
- if (fs.existsSync(path.join(vaultPath, "02_Areas"))) {
4725
- backupItems.push({ id: "areas", name: "Full Areas folder", tier: "Everything in 02_Areas/" });
4726
- }
4727
- const detectedForBackup = AGENTS.filter((a) => a.detect()).map((a) => a.id);
4728
- if (detectedForBackup.length > 0) {
4729
- backupItems.push({ id: "agents", name: "Agent configs", tier: `Rules, skills from ${detectedForBackup.length} agent(s)` });
4730
- }
4731
- const backupChoices = await interactiveSelect(backupItems, { multi: true, preSelected: ["engine"] });
4732
- if (backupChoices && backupChoices.length > 0) {
4733
- const now = new Date();
4734
- const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
4735
- const archivesDir = path.join(vaultPath, "04_Archives");
4736
- if (backupChoices.includes("engine")) {
4737
- const engineDir = path.join(vaultPath, "02_Areas", "Engine");
4738
- const backupDir = path.join(archivesDir, `Engine_Backup_${ts}`);
4739
- try {
4740
- fs.mkdirSync(backupDir, { recursive: true });
4741
- let backed = 0;
4742
- for (const f of fs.readdirSync(engineDir).filter(f => fs.statSync(path.join(engineDir, f)).isFile())) {
4743
- fs.copyFileSync(path.join(engineDir, f), path.join(backupDir, f));
4744
- backed++;
4745
- }
4746
- statusLine("ok", "Backed up", `${backed} Engine files`);
4747
- } catch (err) { barLn(yellow(` Backup failed: ${err.message}`)); }
4748
- }
4749
- if (backupChoices.includes("areas")) {
4750
- try {
4751
- copyDirRecursive(path.join(vaultPath, "02_Areas"), path.join(archivesDir, `Areas_Backup_${ts}`));
4752
- statusLine("ok", "Backed up", "Full Areas folder");
4753
- } catch (err) { barLn(yellow(` Areas backup failed: ${err.message}`)); }
4754
- }
4755
- if (backupChoices.includes("agents")) {
4756
- try {
4757
- const agentBackupDir = path.join(archivesDir, `Agent_Backup_${ts}`);
4758
- fs.mkdirSync(agentBackupDir, { recursive: true });
4759
- let agentsBacked = 0;
4760
- for (const ag of AGENTS.filter((a) => a.detect())) {
4761
- const agDir = path.join(agentBackupDir, ag.id);
4762
- fs.mkdirSync(agDir, { recursive: true });
4763
- for (const cp of (ag.configPaths || [])) {
4764
- if (fs.existsSync(cp.src)) {
4765
- try { fs.copyFileSync(cp.src, path.join(agDir, path.basename(cp.src))); agentsBacked++; } catch {}
4766
- }
4767
- }
4768
- }
4769
- statusLine("ok", "Backed up", `${agentsBacked} agent config files`);
4770
- } catch (err) { barLn(yellow(` Agent backup failed: ${err.message}`)); }
4771
- }
4772
- }
4773
- barLn();
4774
- }
4775
-
4776
- // Step 6: Agent Management
4777
- const visibleAgents = AGENTS.filter((a) => !a.hidden);
4778
- const detectedIds = visibleAgents.filter((a) => a.detect()).map((a) => a.id);
4779
- const cfgPath = path.join(os.homedir(), ".mover", "config.json");
4780
- let currentAgents = [];
4781
- if (fs.existsSync(cfgPath)) {
4782
- try { currentAgents = JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || []; } catch {}
4783
- }
4784
- const preSelectedAgents = [...new Set([...detectedIds, ...currentAgents])];
4785
-
4786
- question(`Agents ${dim("(add or remove)")}`);
4787
- barLn();
4788
- const agentItems = visibleAgents.map((a) => ({
4789
- ...a,
4790
- _detected: detectedIds.includes(a.id),
4791
- }));
4792
- const selectedIds = await interactiveSelect(agentItems, { multi: true, preSelected: preSelectedAgents });
4793
- if (!selectedIds || selectedIds.length === 0) return;
4794
- const selectedAgents = AGENTS.filter((a) => selectedIds.includes(a.id));
4795
- barLn();
4796
-
4797
- // Step 7: Change Detection
4872
+ // Step 5: Change Detection (compare staged vs installed)
4798
4873
  const changes = detectChanges(bundleDir, vaultPath, selectedIds);
4799
4874
  const totalChanged = countChanges(changes);
4800
4875
  question("Change Summary");
@@ -4804,121 +4879,21 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4804
4879
  if (totalChanged === 0) {
4805
4880
  barLn(green(" Already up to date."));
4806
4881
  barLn();
4807
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
4808
- outro(`${green("Done.")} No changes needed. ${dim(`(${elapsed}s)`)}`);
4882
+ outro(green("No changes needed. Engine files may still need migration — run /update."));
4809
4883
  return;
4810
4884
  }
4811
4885
 
4812
- const applyChoice = await interactiveSelect([
4813
- { id: "all", name: "Yes, update all changed files", tier: "" },
4814
- { id: "select", name: "Select individually", tier: "" },
4815
- { id: "cancel", name: "Cancel", tier: "" },
4816
- ], { multi: false, defaultIndex: 0 });
4817
- if (!applyChoice || applyChoice === "cancel") { outro("Cancelled."); return; }
4818
-
4819
- let selectedWorkflows = null;
4820
- let skipHooks = false, skipRules = false, skipTemplates = false;
4821
- if (applyChoice === "select") {
4822
- const changedItems = [];
4823
- const changedPreSelected = [];
4824
- for (const f of changes.workflows.filter((x) => x.status !== "unchanged")) {
4825
- const id = `wf:${f.file}`;
4826
- changedItems.push({ id, name: `/${f.file.replace(".md", "")}`, tier: dim(f.status === "new" ? "new" : "changed") });
4827
- changedPreSelected.push(id);
4828
- }
4829
- for (const f of changes.hooks.filter((x) => x.status !== "unchanged")) {
4830
- const id = `hook:${f.file}`;
4831
- changedItems.push({ id, name: f.file, tier: dim(f.status === "new" ? "new hook" : "hook") });
4832
- changedPreSelected.push(id);
4833
- }
4834
- if (changes.rules === "changed") {
4835
- changedItems.push({ id: "rules", name: "Global Rules", tier: dim("rules") });
4836
- changedPreSelected.push("rules");
4837
- }
4838
- for (const f of changes.templates.filter((x) => x.status !== "unchanged")) {
4839
- const id = `tmpl:${f.file}`;
4840
- changedItems.push({ id, name: f.file.replace(/\\/g, "/"), tier: dim(f.status === "new" ? "new" : "changed") });
4841
- changedPreSelected.push(id);
4842
- }
4843
- if (changedItems.length > 0) {
4844
- question("Select files to update");
4845
- barLn();
4846
- const selectedFileIds = await interactiveSelect(changedItems, { multi: true, preSelected: changedPreSelected });
4847
- if (!selectedFileIds) return;
4848
- const selectedWfFiles = selectedFileIds.filter((id) => id.startsWith("wf:")).map((id) => id.slice(3));
4849
- if (selectedWfFiles.length < changes.workflows.filter((x) => x.status !== "unchanged").length) {
4850
- selectedWorkflows = new Set(selectedWfFiles);
4851
- }
4852
- skipHooks = !selectedFileIds.some((id) => id.startsWith("hook:"));
4853
- skipRules = !selectedFileIds.includes("rules");
4854
- skipTemplates = !selectedFileIds.some((id) => id.startsWith("tmpl:"));
4855
- }
4856
- }
4857
-
4858
- // Step 8: Apply Changes (with progress animation)
4886
+ // Stage confirmation
4859
4887
  barLn();
4860
- question(bold("Applying updates"));
4888
+ statusLine("ok", "Staged", `${totalChanged} updated files at ${dim("~/.mover/src/")}`);
4861
4889
  barLn();
4862
-
4863
- const installSteps = [];
4864
- installSteps.push({ label: "Vault structure", fn: async () => { createVaultStructure(vaultPath); await sleep(100); } });
4865
- if (!skipTemplates) {
4866
- installSteps.push({ label: "Template files", fn: async () => { installTemplateFiles(bundleDir, vaultPath); await sleep(100); } });
4867
- }
4868
-
4869
- const writtenFiles = new Set();
4870
- const skillOpts = { install: true, categories: null, workflows: selectedWorkflows, skipHooks, skipRules, skipTemplates };
4871
- for (const agent of selectedAgents) {
4872
- const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
4873
- const targets = sel ? sel.targets : [agent.id];
4874
- for (const targetId of targets) {
4875
- const fn = AGENT_INSTALLERS[targetId];
4876
- if (!fn) continue;
4877
- const targetReg = AGENT_REGISTRY[targetId];
4878
- const displayName = targetReg ? targetReg.name : agent.name;
4879
- installSteps.push({ label: displayName, fn: async () => { fn(bundleDir, vaultPath, skillOpts, writtenFiles, targetId); await sleep(150); } });
4880
- }
4881
- }
4882
-
4883
- await installProgress(installSteps);
4884
-
4885
- // Step 9: Skills Refresh
4890
+ barLn(bold(" Next step:"));
4891
+ barLn(` Run ${bold("/update")} in your AI agent to apply changes.`);
4892
+ barLn(dim(" The AI will compare each file against your local version,"));
4893
+ barLn(dim(" preserve your customizations, and merge new content."));
4886
4894
  barLn();
4887
- const allSkills = findSkills(bundleDir);
4888
- if (allSkills.length > 0 && selectedAgents.some((a) => a.id !== "aider")) {
4889
- question("Refresh skill categories?");
4890
- barLn();
4891
- const catCounts = {};
4892
- for (const sk of allSkills) { catCounts[sk.category] = (catCounts[sk.category] || 0) + 1; }
4893
- const categoryItems = CATEGORY_META.map((c) => ({
4894
- id: c.id,
4895
- name: `${c.name} ${dim(`(${catCounts[c.id] || 0})`)}`,
4896
- tier: dim(c.desc),
4897
- }));
4898
- const selectedCatIds = await interactiveSelect(categoryItems, { multi: true, preSelected: ["development", "obsidian"] });
4899
- if (selectedCatIds && selectedCatIds.length > 0) {
4900
- const catSet = new Set(selectedCatIds);
4901
- const refreshOpts = { install: true, categories: catSet, workflows: null };
4902
- for (const agent of selectedAgents) {
4903
- const fn = AGENT_INSTALLERS[agent.id];
4904
- if (fn) fn(bundleDir, vaultPath, refreshOpts, writtenFiles, agent.id);
4905
- }
4906
- const skillCount = allSkills.filter((s) => s.category === "tools" || catSet.has(s.category)).length;
4907
- statusLine("ok", "Skills refreshed", `${skillCount} across ${selectedAgents.length} agent(s)`);
4908
- }
4909
- barLn();
4910
- }
4911
-
4912
- // Update version marker + config
4913
- fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
4914
- writeMoverConfig(vaultPath, selectedIds, updateKey);
4915
-
4916
- // Step 10: Summary + Success
4917
- barLn();
4918
- await successAnimation(`Mover OS updated — ${totalChanged} files`);
4919
-
4920
4895
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
4921
- outro(`${green("Done.")} ${dim(`${elapsed}s`)} Run ${bold("/update")} in your agent to sync Engine.`);
4896
+ outro(`Files staged. Run ${bold("/update")} to apply. ${dim(`(${elapsed}s)`)}`);
4922
4897
  }
4923
4898
 
4924
4899
  // ─── Vault Resolution Helper ─────────────────────────────────────────────────
@@ -5284,8 +5259,8 @@ async function main() {
5284
5259
  ],
5285
5260
  { multi: false, defaultIndex: 1 }
5286
5261
  );
5287
- if (!ptChoice) return;
5288
- if (ptChoice === "yes") {
5262
+ if (!ptChoice || ptChoice === "no") { /* skip prayer setup */ }
5263
+ else if (ptChoice === "yes") {
5289
5264
  prayerSetup = true;
5290
5265
  barLn();
5291
5266
  question("How would you like to set up prayer times?");
@@ -5299,7 +5274,9 @@ async function main() {
5299
5274
  ],
5300
5275
  { multi: false, defaultIndex: 0 }
5301
5276
  );
5302
- if (!method || method === "later") { /* skip */ }
5277
+ if (!method || method === "later") {
5278
+ // User cancelled method pick — still enable the setting, no timetable
5279
+ } else {
5303
5280
 
5304
5281
  const moverDir = path.join(os.homedir(), ".mover");
5305
5282
  if (!fs.existsSync(moverDir)) fs.mkdirSync(moverDir, { recursive: true, mode: 0o700 });
@@ -5351,26 +5328,31 @@ async function main() {
5351
5328
  } else if (method === "fetch") {
5352
5329
  barLn();
5353
5330
  const city = await textInput({ label: "City (e.g. London, Watford, Istanbul)", placeholder: "London" });
5354
- if (city === null) return;
5355
- const country = await textInput({ label: "Country", placeholder: "United Kingdom" });
5356
- if (country === null) return;
5357
- barLn();
5358
-
5359
- if (city && country) {
5360
- const sp = spinner("Fetching prayer times");
5361
- const tt = await fetchPrayerTimes(city.trim(), country.trim());
5362
- if (tt && Object.keys(tt.times).length > 0) {
5363
- const ttPath = path.join(moverDir, "prayer-timetable.json");
5364
- fs.writeFileSync(ttPath, JSON.stringify(tt, null, 2), "utf8");
5365
- sp.stop(`Prayer times ${dim(`${Object.keys(tt.times).length} days from aladhan.com`)}`);
5366
- barLn(dim(" Note: these are calculated adhan times, not mosque jama'ah times."));
5367
- barLn(dim(" For your mosque's specific times, run: moveros prayer"));
5331
+ if (!city || !city.trim()) {
5332
+ barLn(yellow(" City cannot be empty. Run moveros prayer later to set up."));
5333
+ } else {
5334
+ const country = await textInput({ label: "Country", placeholder: "United Kingdom" });
5335
+ if (!country || !country.trim()) {
5336
+ barLn(yellow(" Country cannot be empty. Run moveros prayer later to set up."));
5368
5337
  } else {
5369
- sp.stop(yellow("Could not fetch. Run moveros prayer later."));
5338
+ barLn();
5339
+ const sp = spinner("Fetching prayer times");
5340
+ const tt = await fetchPrayerTimes(city.trim(), country.trim());
5341
+ if (tt && Object.keys(tt.times).length > 0) {
5342
+ const ttPath = path.join(moverDir, "prayer-timetable.json");
5343
+ fs.writeFileSync(ttPath, JSON.stringify(tt, null, 2), "utf8");
5344
+ sp.stop(`Prayer times ${dim(`${Object.keys(tt.times).length} days from aladhan.com`)}`);
5345
+ barLn(dim(" Note: these are calculated adhan times, not mosque jama'ah times."));
5346
+ barLn(dim(" For your mosque's specific times, run: moveros prayer"));
5347
+ } else {
5348
+ sp.stop(yellow("Could not fetch prayer times."));
5349
+ barLn(yellow(" Check city/country spelling. Run moveros prayer later to retry."));
5350
+ }
5370
5351
  }
5371
5352
  }
5372
5353
  }
5373
5354
  // method === "later" → just enable the setting, no timetable yet
5355
+ } // end if method !== "later"
5374
5356
  }
5375
5357
  }
5376
5358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mover-os",
3
- "version": "4.5.2",
3
+ "version": "4.5.4",
4
4
  "description": "The self-improving OS for AI agents. Turns Obsidian into an execution engine.",
5
5
  "bin": {
6
6
  "moveros": "install.js"