mover-os 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +218 -0
  2. package/install.js +2775 -0
  3. package/package.json +30 -0
package/install.js ADDED
@@ -0,0 +1,2775 @@
1
+ #!/usr/bin/env node
2
+ // ══════════════════════════════════════════════════════════════════════════════
3
+ // Mover OS — Interactive TUI Installer
4
+ // Zero dependencies. Node.js 18+.
5
+ //
6
+ // Usage:
7
+ // npx moveros
8
+ // npx moveros --key YOUR_KEY --vault ~/vault
9
+ // ══════════════════════════════════════════════════════════════════════════════
10
+
11
+ const readline = require("readline");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ const { execSync } = require("child_process");
16
+
17
+ const VERSION = "4";
18
+
19
+ // ─── ANSI ────────────────────────────────────────────────────────────────────
20
+ const IS_TTY = process.stdout.isTTY && process.stdin.isTTY;
21
+ const S = IS_TTY
22
+ ? {
23
+ reset: "\x1b[0m",
24
+ bold: "\x1b[1m",
25
+ dim: "\x1b[2m",
26
+ italic: "\x1b[3m",
27
+ underline: "\x1b[4m",
28
+ green: "\x1b[32m",
29
+ yellow: "\x1b[33m",
30
+ cyan: "\x1b[36m",
31
+ red: "\x1b[31m",
32
+ magenta: "\x1b[35m",
33
+ blue: "\x1b[34m",
34
+ white: "\x1b[37m",
35
+ gray: "\x1b[90m",
36
+ inverse: "\x1b[7m",
37
+ hide: "\x1b[?25l",
38
+ show: "\x1b[?25h",
39
+ // 256-color for gradient
40
+ fg: (n) => `\x1b[38;5;${n}m`,
41
+ }
42
+ : {
43
+ reset: "", bold: "", dim: "", italic: "", underline: "",
44
+ green: "", yellow: "", cyan: "", red: "", magenta: "", blue: "",
45
+ white: "", gray: "", inverse: "", hide: "", show: "",
46
+ fg: () => "",
47
+ };
48
+
49
+ const w = (s) => process.stdout.write(s);
50
+ const ln = (s = "") => w(s + "\n");
51
+ const bold = (s) => `${S.bold}${s}${S.reset}`;
52
+ const dim = (s) => `${S.dim}${s}${S.reset}`;
53
+ const green = (s) => `${S.green}${s}${S.reset}`;
54
+ const yellow = (s) => `${S.yellow}${s}${S.reset}`;
55
+ const cyan = (s) => `${S.cyan}${s}${S.reset}`;
56
+ const red = (s) => `${S.red}${s}${S.reset}`;
57
+ const gray = (s) => `${S.gray}${s}${S.reset}`;
58
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
59
+
60
+ // Gradient text using 256-color palette (white → gray, matching website monochrome theme)
61
+ const GRADIENT = [255, 255, 254, 253, 252, 251, 250, 249, 248, 247];
62
+ function gradient(text) {
63
+ if (!IS_TTY) return text;
64
+ const chars = [...text];
65
+ return chars.map((ch, i) => {
66
+ if (ch === " ") return ch;
67
+ const idx = Math.floor((i / Math.max(chars.length - 1, 1)) * (GRADIENT.length - 1));
68
+ return `${S.fg(GRADIENT[idx])}${ch}`;
69
+ }).join("") + S.reset;
70
+ }
71
+
72
+ // ─── Terminal cleanup ────────────────────────────────────────────────────────
73
+ function cleanup() {
74
+ w(S.show);
75
+ try { if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false); } catch {}
76
+ }
77
+ process.on("exit", cleanup);
78
+ process.on("SIGINT", () => { cleanup(); ln(); process.exit(0); });
79
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
80
+
81
+ // ─── Branded header ─────────────────────────────────────────────────────────
82
+ const LOGO = [
83
+ " ███╗ ███╗ ██████╗ ██╗ ██╗███████╗██████╗ ",
84
+ " ████╗ ████║██╔═══██╗██║ ██║██╔════╝██╔══██╗",
85
+ " ██╔████╔██║██║ ██║██║ ██║█████╗ ██████╔╝",
86
+ " ██║╚██╔╝██║██║ ██║╚██╗ ██╔╝██╔══╝ ██╔══██╗",
87
+ " ██║ ╚═╝ ██║╚██████╔╝ ╚████╔╝ ███████╗██║ ██║",
88
+ " ╚═╝ ╚═╝ ╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝",
89
+ " ██████╗ ███████╗",
90
+ " ██╔═══██╗██╔════╝",
91
+ " ██║ ██║███████╗",
92
+ " ██║ ██║╚════██║",
93
+ " ╚██████╔╝███████║",
94
+ " ╚═════╝ ╚══════╝",
95
+ ];
96
+
97
+ function printHeader() {
98
+ ln();
99
+ for (const line of LOGO) {
100
+ ln(gradient(line));
101
+ }
102
+ ln();
103
+ ln(` ${dim(`v${VERSION}`)} ${gray("the agentic operating system for obsidian")}`);
104
+ ln();
105
+ ln(gray(" ─────────────────────────────────────────────"));
106
+ ln();
107
+ }
108
+
109
+ // ─── Clack-style frame ──────────────────────────────────────────────────────
110
+ const BAR_COLOR = S.cyan;
111
+ const bar = () => w(`${BAR_COLOR}│${S.reset}`);
112
+ const barLn = (text = "") => ln(`${BAR_COLOR}│${S.reset} ${text}`);
113
+ const result = (text) => ln(`${S.green}◇${S.reset} ${dim(text)}`);
114
+ const question = (text) => ln(`${BAR_COLOR}◆${S.reset} ${text}`);
115
+ const outro = (text) => { ln(`${BAR_COLOR}└${S.reset} ${text}`); ln(); };
116
+
117
+ // ─── Animated spinner ───────────────────────────────────────────────────────
118
+ const SPIN_FRAMES = ["◐", "◓", "◑", "◒"];
119
+ function spinner(label) {
120
+ if (!IS_TTY) {
121
+ return { stop: (finalLabel) => { ln(`${BAR_COLOR}│${S.reset} ${S.green}\u2713${S.reset} ${finalLabel || label}`); } };
122
+ }
123
+ let i = 0;
124
+ w(S.hide);
125
+ w(`${BAR_COLOR}│${S.reset} ${S.cyan}${SPIN_FRAMES[0]}${S.reset} ${label}`);
126
+ const timer = setInterval(() => {
127
+ i = (i + 1) % SPIN_FRAMES.length;
128
+ w(`\x1b[2K\r${BAR_COLOR}│${S.reset} ${S.cyan}${SPIN_FRAMES[i]}${S.reset} ${label}`);
129
+ }, 80);
130
+ return {
131
+ stop: (finalLabel) => {
132
+ clearInterval(timer);
133
+ w(`\x1b[2K\r${BAR_COLOR}│${S.reset} ${S.green}\u2713${S.reset} ${finalLabel || label}\n`);
134
+ w(S.show);
135
+ },
136
+ };
137
+ }
138
+
139
+ // ─── UI: Text input (raw mode) ──────────────────────────────────────────────
140
+ function textInput({ label = "", initial = "", mask = null, placeholder = "" }) {
141
+ return new Promise((resolve) => {
142
+ question(label);
143
+
144
+ if (!IS_TTY) {
145
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
146
+ rl.question(`${BAR_COLOR}│${S.reset} `, (ans) => { rl.close(); resolve(ans || initial); });
147
+ return;
148
+ }
149
+
150
+ const { stdin } = process;
151
+ stdin.setRawMode(true);
152
+ stdin.resume();
153
+ stdin.setEncoding("utf8");
154
+
155
+ let value = initial;
156
+ let pos = value.length;
157
+
158
+ const render = () => {
159
+ w("\x1b[2K\r");
160
+ const prefix = `${BAR_COLOR}│${S.reset} `;
161
+ if (value.length === 0 && placeholder) {
162
+ w(prefix + dim(placeholder));
163
+ } else if (mask) {
164
+ w(prefix + mask.repeat(value.length));
165
+ } else {
166
+ w(prefix + value);
167
+ }
168
+ // Position cursor
169
+ if (!mask) {
170
+ const back = value.length - pos;
171
+ if (back > 0) w(`\x1b[${back}D`);
172
+ }
173
+ };
174
+
175
+ render();
176
+
177
+ const handler = (data) => {
178
+ if (data === "\r" || data === "\n") {
179
+ stdin.removeListener("data", handler);
180
+ stdin.setRawMode(false);
181
+ stdin.pause();
182
+ ln();
183
+ barLn();
184
+ if (mask) {
185
+ result("Entered");
186
+ } else {
187
+ result(value || initial);
188
+ }
189
+ resolve(value || initial);
190
+ return;
191
+ }
192
+ if (data === "\x03") { cleanup(); ln(); process.exit(0); }
193
+
194
+ if (data === "\x1b[C") { pos = Math.min(pos + 1, value.length); }
195
+ else if (data === "\x1b[D") { pos = Math.max(0, pos - 1); }
196
+ else if (data === "\x1b[H" || data === "\x01") { pos = 0; }
197
+ else if (data === "\x1b[F" || data === "\x05") { pos = value.length; }
198
+ else if (data === "\x15") { value = ""; pos = 0; }
199
+ else if (data === "\x7f" || data === "\x08") {
200
+ if (pos > 0) {
201
+ value = value.slice(0, pos - 1) + value.slice(pos);
202
+ pos--;
203
+ }
204
+ }
205
+ else if (data === "\x1b[3~") {
206
+ if (pos < value.length) {
207
+ value = value.slice(0, pos) + value.slice(pos + 1);
208
+ }
209
+ }
210
+ else if (data.startsWith("\x1b")) { /* ignore escape sequences */ }
211
+ else {
212
+ for (const ch of data) {
213
+ if (ch.charCodeAt(0) >= 32) {
214
+ value = value.slice(0, pos) + ch + value.slice(pos);
215
+ pos++;
216
+ }
217
+ }
218
+ }
219
+ render();
220
+ };
221
+
222
+ stdin.on("data", handler);
223
+ });
224
+ }
225
+
226
+ // ─── UI: Interactive select (raw mode) ───────────────────────────────────────
227
+ function interactiveSelect(items, { multi = false, preSelected = [], defaultIndex = 0 } = {}) {
228
+ return new Promise((resolve) => {
229
+ if (!IS_TTY) {
230
+ resolve(multi ? preSelected : items[defaultIndex]?.id);
231
+ return;
232
+ }
233
+
234
+ const { stdin } = process;
235
+ stdin.setRawMode(true);
236
+ stdin.resume();
237
+ stdin.setEncoding("utf8");
238
+ w(S.hide);
239
+
240
+ let cursor = defaultIndex;
241
+ const selected = new Set(preSelected);
242
+ let prevLines = 0;
243
+
244
+ const render = () => {
245
+ if (prevLines > 0) w(`\x1b[${prevLines}A`);
246
+
247
+ let lines = 0;
248
+ for (let i = 0; i < items.length; i++) {
249
+ const item = items[i];
250
+ const active = i === cursor;
251
+ const checked = selected.has(item.id);
252
+
253
+ const icon = multi
254
+ ? (checked ? green("◼") : dim("◻"))
255
+ : (active ? cyan("●") : dim("○"));
256
+ const label = active ? bold(item.name) : item.name;
257
+ const padded = strip(label).padEnd(20);
258
+ const styledPadded = active ? bold(padded) : padded;
259
+ const tag = item._detected ? dim("(detected)") : "";
260
+
261
+ w(`\x1b[2K${BAR_COLOR}│${S.reset} ${icon} ${styledPadded}${tag}\n`);
262
+ lines++;
263
+ }
264
+
265
+ // Tooltip: description of highlighted item
266
+ const tip = items[cursor]?.tier || "";
267
+ w(`\x1b[2K${BAR_COLOR}│${S.reset}\n`);
268
+ lines++;
269
+ w(`\x1b[2K${BAR_COLOR}│${S.reset} ${tip ? `${dim("→")} ${tip}` : ""}\n`);
270
+ lines++;
271
+
272
+ const hint = multi
273
+ ? dim(" ↑↓ navigate space select a all enter confirm")
274
+ : dim(" ↑↓ navigate enter select");
275
+ w(`\x1b[2K${BAR_COLOR}│${S.reset}${hint}\n`);
276
+ lines++;
277
+
278
+ prevLines = lines;
279
+ };
280
+
281
+ render();
282
+
283
+ const handler = (data) => {
284
+ if (data === "\x1b[A") { cursor = (cursor - 1 + items.length) % items.length; }
285
+ else if (data === "\x1b[B") { cursor = (cursor + 1) % items.length; }
286
+ else if (data === " " && multi) {
287
+ const id = items[cursor].id;
288
+ if (selected.has(id)) selected.delete(id);
289
+ else selected.add(id);
290
+ }
291
+ else if ((data === "a" || data === "A") && multi) {
292
+ if (selected.size === items.length) selected.clear();
293
+ else items.forEach((i) => selected.add(i.id));
294
+ }
295
+ else if (data === "\r" || data === "\n") {
296
+ stdin.removeListener("data", handler);
297
+ stdin.setRawMode(false);
298
+ stdin.pause();
299
+
300
+ // Clear rendered lines
301
+ if (prevLines > 0) {
302
+ w(`\x1b[${prevLines}A`);
303
+ for (let i = 0; i < prevLines; i++) w("\x1b[2K\n");
304
+ w(`\x1b[${prevLines}A`);
305
+ }
306
+ w(S.show);
307
+
308
+ if (multi) {
309
+ const names = items.filter((i) => selected.has(i.id)).map((i) => i.name);
310
+ barLn();
311
+ result(names.length > 0 ? names.join(", ") : "None");
312
+ resolve([...selected]);
313
+ } else {
314
+ barLn();
315
+ result(items[cursor].name);
316
+ resolve(items[cursor].id);
317
+ }
318
+ return;
319
+ }
320
+ else if (data === "\x03") { cleanup(); ln(); process.exit(0); }
321
+
322
+ render();
323
+ };
324
+
325
+ stdin.on("data", handler);
326
+ });
327
+ }
328
+
329
+ // ─── License validation (Polar) ─────────────────────────────────────────────
330
+ // All license keys are validated server-side via Polar API.
331
+ // No hardcoded keys — every key must be a real Polar license.
332
+
333
+ async function validateKey(key) {
334
+ if (!key) return false;
335
+ const k = key.trim();
336
+
337
+ // Polar license key validation
338
+ try {
339
+ const https = require("https");
340
+ const result = await new Promise((resolve, reject) => {
341
+ const body = JSON.stringify({ key: k, organization_id: POLAR_ORG_ID });
342
+ const req = https.request({
343
+ hostname: "api.polar.sh",
344
+ path: "/v1/customer-portal/license-keys/validate",
345
+ method: "POST",
346
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
347
+ timeout: 10000,
348
+ }, (res) => {
349
+ let data = "";
350
+ res.on("data", (chunk) => data += chunk);
351
+ res.on("end", () => {
352
+ try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid response")); }
353
+ });
354
+ });
355
+ req.on("error", reject);
356
+ req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
357
+ req.write(body);
358
+ req.end();
359
+ });
360
+ return result.status === "granted";
361
+ } catch {
362
+ // Network error — cannot validate without Polar
363
+ return false;
364
+ }
365
+ }
366
+
367
+ async function activateKey(key) {
368
+ if (!key) return;
369
+ try {
370
+ const https = require("https");
371
+ const body = JSON.stringify({ key: key.trim(), organization_id: POLAR_ORG_ID, label: os.hostname() });
372
+ await new Promise((resolve, reject) => {
373
+ const req = https.request({
374
+ hostname: "api.polar.sh",
375
+ path: "/v1/customer-portal/license-keys/activate",
376
+ method: "POST",
377
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
378
+ timeout: 10000,
379
+ }, (res) => {
380
+ let data = "";
381
+ res.on("data", (chunk) => data += chunk);
382
+ res.on("end", () => resolve(data));
383
+ });
384
+ req.on("error", reject);
385
+ req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
386
+ req.write(body);
387
+ req.end();
388
+ });
389
+ } catch { /* Activation failure is non-blocking */ }
390
+ }
391
+
392
+ // Polar organization ID
393
+ const POLAR_ORG_ID = process.env.POLAR_ORG_ID || "ba863394-6bca-4965-952a-06b7c017adb7";
394
+
395
+ // ─── Payload Download ────────────────────────────────────────────────────────
396
+ const DOWNLOAD_URL = "https://moveros.dev/api/download";
397
+
398
+ async function downloadPayload(key) {
399
+ const https = require("https");
400
+ const tmpDir = path.join(os.tmpdir(), `moveros-${Date.now()}`);
401
+ fs.mkdirSync(tmpDir, { recursive: true });
402
+ const tarPath = path.join(tmpDir, "payload.tar.gz");
403
+
404
+ // Download tarball
405
+ await new Promise((resolve, reject) => {
406
+ const url = new URL(DOWNLOAD_URL);
407
+ const req = https.request({
408
+ hostname: url.hostname,
409
+ path: url.pathname,
410
+ method: "GET",
411
+ headers: { "X-License-Key": key.trim() },
412
+ timeout: 60000,
413
+ }, (res) => {
414
+ if (res.statusCode === 301 || res.statusCode === 302) {
415
+ // Follow redirect — only to trusted domains
416
+ const redirectUrl = new URL(res.headers.location);
417
+ const trusted = ["github.com", "objects.githubusercontent.com", "moveros.dev"];
418
+ if (!trusted.some((d) => redirectUrl.hostname === d || redirectUrl.hostname.endsWith("." + d))) {
419
+ reject(new Error("Untrusted redirect domain"));
420
+ return;
421
+ }
422
+ const req2 = https.request({
423
+ hostname: redirectUrl.hostname,
424
+ path: redirectUrl.pathname + (redirectUrl.search || ""),
425
+ method: "GET",
426
+ timeout: 60000,
427
+ }, (res2) => {
428
+ const chunks = [];
429
+ res2.on("data", (c) => chunks.push(c));
430
+ res2.on("end", () => {
431
+ fs.writeFileSync(tarPath, Buffer.concat(chunks));
432
+ resolve();
433
+ });
434
+ });
435
+ req2.on("error", reject);
436
+ req2.on("timeout", () => { req2.destroy(); reject(new Error("Timeout")); });
437
+ req2.end();
438
+ return;
439
+ }
440
+ if (res.statusCode === 401) {
441
+ let body = "";
442
+ res.on("data", (c) => body += c);
443
+ res.on("end", () => reject(new Error("License key rejected by server")));
444
+ return;
445
+ }
446
+ if (res.statusCode !== 200) {
447
+ reject(new Error(`Download failed (HTTP ${res.statusCode})`));
448
+ return;
449
+ }
450
+ const chunks = [];
451
+ res.on("data", (c) => chunks.push(c));
452
+ res.on("end", () => {
453
+ fs.writeFileSync(tarPath, Buffer.concat(chunks));
454
+ resolve();
455
+ });
456
+ });
457
+ req.on("error", reject);
458
+ req.on("timeout", () => { req.destroy(); reject(new Error("Download timeout")); });
459
+ req.end();
460
+ });
461
+
462
+ // Extract tarball
463
+ try {
464
+ execSync(`tar -xzf "${tarPath}" -C "${tmpDir}"`, { stdio: "ignore" });
465
+ } catch {
466
+ throw new Error("Failed to extract payload. Ensure 'tar' is available.");
467
+ }
468
+
469
+ // Clean up tarball
470
+ try { fs.unlinkSync(tarPath); } catch {}
471
+
472
+ return tmpDir;
473
+ }
474
+
475
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
476
+ function parseArgs() {
477
+ const args = process.argv.slice(2);
478
+ const opts = { vault: "", key: "", update: false };
479
+ for (let i = 0; i < args.length; i++) {
480
+ if (args[i] === "--vault" && args[i + 1]) opts.vault = args[++i];
481
+ else if (args[i] === "--key" && args[i + 1]) opts.key = args[++i];
482
+ else if (args[i] === "--update" || args[i] === "-u") opts.update = true;
483
+ else if (args[i] === "--help" || args[i] === "-h") {
484
+ ln();
485
+ ln(` ${bold("mover os")} ${dim("installer")}`);
486
+ ln();
487
+ ln(` ${dim("Usage")} npx moveros`);
488
+ ln();
489
+ ln(` ${dim("Options")}`);
490
+ ln(` --key KEY License key (skip interactive prompt)`);
491
+ ln(` --vault PATH Obsidian vault path (skip detection)`);
492
+ ln(` --update, -u Quick update (auto-detect vault + agents, no prompts)`);
493
+ ln();
494
+ process.exit(0);
495
+ }
496
+ }
497
+ return opts;
498
+ }
499
+
500
+ // ─── Obsidian vault detection ───────────────────────────────────────────────
501
+ function detectObsidianVaults() {
502
+ const configPaths = {
503
+ win32: path.join(os.homedir(), "AppData", "Roaming", "Obsidian", "obsidian.json"),
504
+ darwin: path.join(os.homedir(), "Library", "Application Support", "obsidian", "obsidian.json"),
505
+ linux: path.join(os.homedir(), ".config", "obsidian", "obsidian.json"),
506
+ };
507
+ const configPath = configPaths[process.platform];
508
+ if (!configPath || !fs.existsSync(configPath)) return [];
509
+ try {
510
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
511
+ if (!config.vaults) return [];
512
+ return Object.values(config.vaults)
513
+ .map((v) => v.path)
514
+ .filter((p) => p && fs.existsSync(p))
515
+ .sort();
516
+ } catch {
517
+ return [];
518
+ }
519
+ }
520
+
521
+ // ─── Change detection (update mode) ─────────────────────────────────────────
522
+ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
523
+ const home = os.homedir();
524
+ const result = { workflows: [], hooks: [], rules: null, templates: [] };
525
+
526
+ // --- Workflows: compare source vs first installed destination ---
527
+ const wfSrc = path.join(bundleDir, "src", "workflows");
528
+ const wfDests = [
529
+ selectedAgentIds.includes("claude-code") && path.join(home, ".claude", "commands"),
530
+ selectedAgentIds.includes("cursor") && path.join(home, ".cursor", "commands"),
531
+ selectedAgentIds.includes("antigravity") && path.join(home, ".gemini", "antigravity", "global_workflows"),
532
+ ].filter(Boolean);
533
+ const wfDest = wfDests.find((d) => fs.existsSync(d));
534
+
535
+ if (fs.existsSync(wfSrc)) {
536
+ for (const file of fs.readdirSync(wfSrc).filter((f) => f.endsWith(".md"))) {
537
+ const srcContent = fs.readFileSync(path.join(wfSrc, file), "utf8");
538
+ const destFile = wfDest && path.join(wfDest, file);
539
+ if (!destFile || !fs.existsSync(destFile)) {
540
+ result.workflows.push({ file, status: "new" });
541
+ } else {
542
+ const destContent = fs.readFileSync(destFile, "utf8");
543
+ result.workflows.push({
544
+ file,
545
+ status: srcContent === destContent ? "unchanged" : "changed",
546
+ });
547
+ }
548
+ }
549
+ }
550
+
551
+ // --- Hooks: compare with CRLF normalization ---
552
+ const hooksSrc = path.join(bundleDir, "src", "hooks");
553
+ const hooksDest = path.join(home, ".claude", "hooks");
554
+ if (fs.existsSync(hooksSrc) && selectedAgentIds.includes("claude-code")) {
555
+ for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".sh") || f.endsWith(".md"))) {
556
+ const srcContent = fs.readFileSync(path.join(hooksSrc, file), "utf8")
557
+ .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
558
+ const destFile = path.join(hooksDest, file);
559
+ if (!fs.existsSync(destFile)) {
560
+ result.hooks.push({ file, status: "new" });
561
+ } else {
562
+ const destContent = fs.readFileSync(destFile, "utf8")
563
+ .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
564
+ result.hooks.push({
565
+ file,
566
+ status: srcContent === destContent ? "unchanged" : "changed",
567
+ });
568
+ }
569
+ }
570
+ }
571
+
572
+ // --- Rules: compare source vs first installed destination ---
573
+ const rulesSrc = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
574
+ const rulesDests = [
575
+ selectedAgentIds.includes("claude-code") && path.join(home, ".claude", "CLAUDE.md"),
576
+ selectedAgentIds.includes("cursor") && path.join(home, ".cursor", "rules", "mover-os.mdc"),
577
+ selectedAgentIds.includes("gemini-cli") && path.join(home, ".gemini", "GEMINI.md"),
578
+ ].filter(Boolean);
579
+ const rulesDest = rulesDests.find((d) => fs.existsSync(d));
580
+ if (fs.existsSync(rulesSrc) && rulesDest) {
581
+ const srcContent = fs.readFileSync(rulesSrc, "utf8");
582
+ const destContent = fs.readFileSync(rulesDest, "utf8");
583
+ result.rules = srcContent === destContent ? "unchanged" : "changed";
584
+ } else {
585
+ result.rules = "unchanged";
586
+ }
587
+
588
+ // --- Templates: non-Engine vault files ---
589
+ const structDir = path.join(bundleDir, "src", "structure");
590
+ if (fs.existsSync(structDir)) {
591
+ const walkTemplates = (dir, rel) => {
592
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
593
+ const entryRel = path.join(rel, entry.name);
594
+ if (entry.isDirectory()) {
595
+ walkTemplates(path.join(dir, entry.name), entryRel);
596
+ } else {
597
+ const relNorm = entryRel.replace(/\\/g, "/");
598
+ if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
599
+ const srcContent = fs.readFileSync(path.join(dir, entry.name), "utf8");
600
+ const destFile = path.join(vaultPath, entryRel);
601
+ if (!fs.existsSync(destFile)) {
602
+ result.templates.push({ file: entryRel, status: "new" });
603
+ } else {
604
+ const destContent = fs.readFileSync(destFile, "utf8");
605
+ result.templates.push({
606
+ file: entryRel,
607
+ status: srcContent === destContent ? "unchanged" : "changed",
608
+ });
609
+ }
610
+ }
611
+ }
612
+ };
613
+ walkTemplates(structDir, "");
614
+ }
615
+
616
+ return result;
617
+ }
618
+
619
+ function countChanges(changes) {
620
+ let n = 0;
621
+ n += changes.workflows.filter((f) => f.status !== "unchanged").length;
622
+ n += changes.hooks.filter((f) => f.status !== "unchanged").length;
623
+ if (changes.rules === "changed") n++;
624
+ n += changes.templates.filter((f) => f.status !== "unchanged").length;
625
+ return n;
626
+ }
627
+
628
+ function displayChangeSummary(changes, installedVersion, newVersion) {
629
+ // Version line
630
+ if (installedVersion && newVersion && installedVersion !== newVersion) {
631
+ barLn(` Installed: ${dim(installedVersion)} → ${bold(newVersion)}`);
632
+ } else if (installedVersion === newVersion) {
633
+ barLn(` Version: ${dim(installedVersion)} (files may differ)`);
634
+ }
635
+ barLn();
636
+
637
+ // Workflows
638
+ const wfChanged = changes.workflows.filter((f) => f.status === "changed");
639
+ const wfNew = changes.workflows.filter((f) => f.status === "new");
640
+ const wfUnchanged = changes.workflows.filter((f) => f.status === "unchanged");
641
+ barLn(` Workflows ${dim(`(${wfChanged.length + wfNew.length} changed, ${wfUnchanged.length} unchanged)`)}:`);
642
+ for (const f of wfChanged) barLn(` ${yellow("✦")} /${f.file.replace(".md", "")}`);
643
+ for (const f of wfNew) barLn(` ${green("+")} /${f.file.replace(".md", "")} ${dim("(new)")}`);
644
+ if (wfChanged.length === 0 && wfNew.length === 0) barLn(` ${dim("all up to date")}`);
645
+ barLn();
646
+
647
+ // Hooks
648
+ if (changes.hooks.length > 0) {
649
+ const hkChanged = changes.hooks.filter((f) => f.status === "changed");
650
+ const hkNew = changes.hooks.filter((f) => f.status === "new");
651
+ const hkUnchanged = changes.hooks.filter((f) => f.status === "unchanged");
652
+ barLn(` Hooks ${dim(`(${hkChanged.length + hkNew.length} changed, ${hkUnchanged.length} unchanged)`)}:`);
653
+ for (const f of hkChanged) barLn(` ${yellow("✦")} ${f.file}`);
654
+ for (const f of hkNew) barLn(` ${green("+")} ${f.file} ${dim("(new)")}`);
655
+ if (hkChanged.length === 0 && hkNew.length === 0) barLn(` ${dim("all up to date")}`);
656
+ barLn();
657
+ }
658
+
659
+ // Rules
660
+ barLn(` Rules: ${changes.rules === "changed" ? yellow("changed") : dim("unchanged")}`);
661
+
662
+ // Templates
663
+ const tmplChanged = changes.templates.filter((f) => f.status === "changed");
664
+ const tmplNew = changes.templates.filter((f) => f.status === "new");
665
+ if (tmplChanged.length > 0 || tmplNew.length > 0) {
666
+ barLn(` Templates ${dim(`(${tmplChanged.length + tmplNew.length} changed)`)}:`);
667
+ for (const f of tmplChanged) barLn(` ${yellow("✦")} ${f.file.replace(/\\/g, "/")}`);
668
+ for (const f of tmplNew) barLn(` ${green("+")} ${f.file.replace(/\\/g, "/")} ${dim("(new)")}`);
669
+ } else {
670
+ barLn(` Templates: ${dim("unchanged")}`);
671
+ }
672
+ barLn();
673
+ }
674
+
675
+ // ─── Engine file detection ──────────────────────────────────────────────────
676
+ function detectEngineFiles(vaultPath) {
677
+ const engineDir = path.join(vaultPath, "02_Areas", "Engine");
678
+ if (!fs.existsSync(engineDir)) return { exists: false, files: [] };
679
+ const coreFiles = [
680
+ "Identity_Prime.md", "Strategy.md", "Active_Context.md",
681
+ "Mover_Dossier.md", "Auto_Learnings.md", "Goals.md",
682
+ ];
683
+ const found = coreFiles.filter((f) => {
684
+ const p = path.join(engineDir, f);
685
+ if (!fs.existsSync(p)) return false;
686
+ // Template files have placeholder content — check if user has actually written to them
687
+ try {
688
+ const content = fs.readFileSync(p, "utf8");
689
+ return content.length > 200; // Templates are ~100-200 chars of scaffolding
690
+ } catch { return false; }
691
+ });
692
+ return { exists: found.length > 0, files: found };
693
+ }
694
+
695
+ // ─── Uninstall ──────────────────────────────────────────────────────────────
696
+ async function runUninstall(vaultPath) {
697
+ ln();
698
+ for (const line of LOGO) {
699
+ ln(gradient(line));
700
+ }
701
+ ln();
702
+
703
+ const home = os.homedir();
704
+
705
+ // ── Build category map (only include categories that actually exist on disk) ──
706
+ const categories = [];
707
+
708
+ // Rules
709
+ const rulesPaths = [
710
+ { label: "Claude Code rules", path: path.join(home, ".claude", "CLAUDE.md") },
711
+ { label: "Cursor rules", path: path.join(home, ".cursor", "rules", "mover-os.mdc") },
712
+ { label: "Antigravity rules", path: path.join(home, ".gemini", "GEMINI.md") },
713
+ { label: "Codex instructions", path: path.join(home, ".codex", "instructions.md") },
714
+ { label: "Windsurf rules", path: path.join(home, ".windsurfrules") },
715
+ ];
716
+ if (vaultPath) {
717
+ rulesPaths.push(
718
+ { label: "AGENTS.md", path: path.join(vaultPath, "AGENTS.md") },
719
+ { label: "SOUL.md", path: path.join(vaultPath, "SOUL.md") },
720
+ { label: ".cursorrules", path: path.join(vaultPath, ".cursorrules") },
721
+ { label: ".clinerules", path: path.join(vaultPath, ".clinerules"), dir: true },
722
+ { label: ".windsurfrules", path: path.join(vaultPath, ".windsurfrules") },
723
+ );
724
+ }
725
+ const rulesExist = rulesPaths.some(p => fs.existsSync(p.path));
726
+ if (rulesExist) categories.push({ id: "rules", name: "Rules", description: "Global rules files for all agents", items: rulesPaths });
727
+
728
+ // Skills
729
+ const skillsPaths = [
730
+ { label: "Claude Code skills", path: path.join(home, ".claude", "skills"), dir: true, keepBuiltins: true },
731
+ { label: "Cursor skills", path: path.join(home, ".cursor", "skills"), dir: true },
732
+ { label: "Codex skills", path: path.join(home, ".codex", "skills"), dir: true },
733
+ { label: "Windsurf skills", path: path.join(home, ".windsurf", "skills"), dir: true },
734
+ { label: "Antigravity skills", path: path.join(home, ".gemini", "antigravity", "skills"), dir: true },
735
+ ];
736
+ const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
737
+ if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
738
+
739
+ // Commands / Workflows
740
+ const commandsPaths = [
741
+ { label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true },
742
+ { label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true },
743
+ { label: "Antigravity workflows", path: path.join(home, ".gemini", "antigravity", "global_workflows"), dir: true },
744
+ ];
745
+ const commandsExist = commandsPaths.some(p => fs.existsSync(p.path));
746
+ if (commandsExist) categories.push({ id: "commands", name: "Commands & Workflows", description: "22 slash commands / workflows", items: commandsPaths });
747
+
748
+ // Hooks
749
+ const hooksPaths = [
750
+ { label: "Claude Code hooks", path: path.join(home, ".claude", "hooks"), dir: true },
751
+ ];
752
+ const hooksExist = hooksPaths.some(p => fs.existsSync(p.path));
753
+ if (hooksExist) categories.push({ id: "hooks", name: "Hooks", description: "6 Claude Code lifecycle hooks", items: hooksPaths });
754
+
755
+ // Vault structure (only if vault has Mover OS structure)
756
+ if (vaultPath) {
757
+ const vaultStructurePaths = [
758
+ { label: "00_Inbox/", path: path.join(vaultPath, "00_Inbox"), dir: true },
759
+ { label: "01_Projects/", path: path.join(vaultPath, "01_Projects"), dir: true },
760
+ { label: "02_Areas/", path: path.join(vaultPath, "02_Areas"), dir: true },
761
+ { label: "03_Library/", path: path.join(vaultPath, "03_Library"), dir: true },
762
+ { label: "04_Archives/", path: path.join(vaultPath, "04_Archives"), dir: true },
763
+ { label: "Templates/", path: path.join(vaultPath, "Templates"), dir: true },
764
+ ];
765
+ const structureExists = vaultStructurePaths.some(p => fs.existsSync(p.path));
766
+ if (structureExists) categories.push({ id: "vault", name: "Vault Structure", description: "PARA folders + Engine (DESTRUCTIVE — deletes your data)", items: vaultStructurePaths, destructive: true });
767
+ }
768
+
769
+ // Mover OS source folder (if cloned into vault)
770
+ if (vaultPath) {
771
+ const moverBundlePath = path.join(vaultPath, "01_Projects", "Mover OS Bundle");
772
+ if (fs.existsSync(moverBundlePath)) {
773
+ categories.push({ id: "bundle", name: "Mover OS Bundle", description: "01_Projects/Mover OS Bundle/ source folder", items: [
774
+ { label: "Mover OS Bundle/", path: moverBundlePath, dir: true },
775
+ ], destructive: true });
776
+ }
777
+ }
778
+
779
+ // Config files
780
+ const configPaths = [];
781
+ if (vaultPath) {
782
+ configPaths.push(
783
+ { label: ".mover-version", path: path.join(vaultPath, ".mover-version") },
784
+ { label: "Claude settings", path: path.join(vaultPath, ".claude", "settings.json") },
785
+ );
786
+ }
787
+ const configExists = configPaths.some(p => fs.existsSync(p.path));
788
+ if (configExists) categories.push({ id: "config", name: "Config Files", description: ".mover-version, settings.json", items: configPaths });
789
+
790
+ if (categories.length === 0) {
791
+ barLn(dim("Nothing to remove — Mover OS not detected."));
792
+ barLn();
793
+ outro(`${green("Done.")}`);
794
+ return;
795
+ }
796
+
797
+ // ── Multi-select: what to uninstall ──
798
+ barLn(bold("What do you want to uninstall?"));
799
+ barLn();
800
+
801
+ const selected = await interactiveSelect(
802
+ categories.map(c => ({
803
+ id: c.id,
804
+ name: c.destructive ? `${c.name} (DESTRUCTIVE)` : c.name,
805
+ tier: c.description,
806
+ _detected: !c.destructive,
807
+ })),
808
+ { multi: true, preSelected: categories.filter(c => !c.destructive).map(c => c.id) }
809
+ );
810
+
811
+ if (!selected || selected.length === 0) {
812
+ barLn(dim("Nothing selected. Uninstall cancelled."));
813
+ barLn();
814
+ outro(`${green("Done.")}`);
815
+ return;
816
+ }
817
+
818
+ // ── Destructive warnings ──
819
+ for (const destructiveId of ["vault", "bundle"]) {
820
+ if (!selected.includes(destructiveId)) continue;
821
+ const warnings = {
822
+ vault: { title: "This will delete your entire vault structure.", detail: "Your Identity, Strategy, Daily Notes, and all Engine files will be permanently deleted.", skip: "Vault structure removal skipped." },
823
+ bundle: { title: "This will delete the Mover OS source folder.", detail: "01_Projects/Mover OS Bundle/ and all its contents will be permanently removed.", skip: "Bundle removal skipped." },
824
+ };
825
+ const w = warnings[destructiveId];
826
+ barLn();
827
+ barLn(`${S.red}${bold("WARNING: " + w.title)}${S.reset}`);
828
+ barLn(`${S.red}${w.detail}${S.reset}`);
829
+ barLn();
830
+ const confirm = await textInput({ label: "Type DELETE to confirm (or press Enter to skip):" });
831
+ if (confirm !== "DELETE") {
832
+ barLn(dim(w.skip));
833
+ selected.splice(selected.indexOf(destructiveId), 1);
834
+ }
835
+ }
836
+
837
+ // ── Execute removal ──
838
+ barLn();
839
+ barLn(dim("removing selected components..."));
840
+ barLn();
841
+ let removed = 0;
842
+
843
+ for (const catId of selected) {
844
+ const cat = categories.find(c => c.id === catId);
845
+ if (!cat) continue;
846
+ for (const item of cat.items) {
847
+ if (!fs.existsSync(item.path)) continue;
848
+ try {
849
+ if (item.dir) {
850
+ if (item.keepBuiltins) {
851
+ for (const entry of fs.readdirSync(item.path, { withFileTypes: true })) {
852
+ if (entry.isDirectory() && fs.existsSync(path.join(item.path, entry.name, "SKILL.md"))) {
853
+ fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true });
854
+ }
855
+ }
856
+ } else {
857
+ fs.rmSync(item.path, { recursive: true, force: true });
858
+ }
859
+ } else {
860
+ fs.unlinkSync(item.path);
861
+ }
862
+ barLn(`${green("\u2713")} ${dim(item.label)}`);
863
+ removed++;
864
+ } catch {}
865
+ }
866
+ }
867
+
868
+ // Deactivate license key (frees activation slot on Polar)
869
+ const configPath = path.join(home, ".mover", "config.json");
870
+ if (fs.existsSync(configPath)) {
871
+ try {
872
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
873
+ if (cfg.licenseKey) {
874
+ barLn(dim("Deactivating license..."));
875
+ try {
876
+ const https = require("https");
877
+ const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label: os.hostname() });
878
+ await new Promise((resolve, reject) => {
879
+ const req = https.request({
880
+ hostname: "api.polar.sh",
881
+ path: "/v1/customer-portal/license-keys/deactivate",
882
+ method: "POST",
883
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
884
+ timeout: 10000,
885
+ }, (res) => {
886
+ let data = "";
887
+ res.on("data", (chunk) => data += chunk);
888
+ res.on("end", () => resolve(data));
889
+ });
890
+ req.on("error", reject);
891
+ req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
892
+ req.write(body);
893
+ req.end();
894
+ });
895
+ barLn(`${green("\u2713")} ${dim("License deactivated — activation slot freed")}`);
896
+ } catch {
897
+ barLn(`${dim("Could not reach Polar — license not deactivated")}`);
898
+ }
899
+ }
900
+ // Remove config file
901
+ fs.unlinkSync(configPath);
902
+ barLn(`${green("\u2713")} ${dim("~/.mover/config.json")}`);
903
+ removed++;
904
+ } catch {}
905
+ }
906
+
907
+ barLn();
908
+ if (!selected.includes("vault")) {
909
+ barLn(dim("Engine files and vault structure left intact."));
910
+ barLn(dim("Your Identity, Strategy, and Daily Notes are untouched."));
911
+ }
912
+ barLn();
913
+ outro(`${green("Done.")} ${removed} items removed.`);
914
+ }
915
+
916
+ // ─── Agent definitions ──────────────────────────────────────────────────────
917
+ const AGENTS = [
918
+ {
919
+ id: "claude-code",
920
+ name: "Claude Code",
921
+ tier: "Full integration — rules, 22 commands, skills, 6 hooks",
922
+ detect: () => cmdExists("claude") || fs.existsSync(path.join(os.homedir(), ".claude")),
923
+ },
924
+ {
925
+ id: "cursor",
926
+ name: "Cursor",
927
+ tier: "Rules, commands, skills, hooks",
928
+ detect: () => fs.existsSync(path.join(os.homedir(), ".cursor")) || cmdExists("cursor"),
929
+ },
930
+ {
931
+ id: "cline",
932
+ name: "Cline",
933
+ tier: "Rules, skills, hooks",
934
+ detect: () => globDirExists(path.join(os.homedir(), ".vscode", "extensions"), "saoudrizwan.claude-dev-*"),
935
+ },
936
+ {
937
+ id: "windsurf",
938
+ name: "Windsurf",
939
+ tier: "Rules, skills, workflows",
940
+ detect: () => fs.existsSync(path.join(os.homedir(), ".windsurf")) || cmdExists("windsurf"),
941
+ },
942
+ {
943
+ id: "gemini-cli",
944
+ name: "Gemini CLI",
945
+ tier: "Rules, skills, commands",
946
+ detect: () => cmdExists("gemini") || fs.existsSync(path.join(os.homedir(), ".gemini", "settings.json")),
947
+ },
948
+ {
949
+ id: "copilot",
950
+ name: "GitHub Copilot",
951
+ tier: "Rules, skills",
952
+ detect: () => cmdExists("gh"),
953
+ },
954
+ {
955
+ id: "codex",
956
+ name: "Codex",
957
+ tier: "Rules, skills",
958
+ detect: () => cmdExists("codex") || fs.existsSync(path.join(os.homedir(), ".codex")),
959
+ },
960
+ // Hidden agents: install functions preserved but not shown in selection UI.
961
+ // Vault-root AGENTS.md provides basic rules for these agents.
962
+ {
963
+ id: "antigravity",
964
+ name: "Antigravity",
965
+ tier: "Rules, skills, workflows",
966
+ detect: () => fs.existsSync(path.join(os.homedir(), ".gemini", "antigravity")),
967
+ },
968
+ // Hidden agents: install functions preserved but not shown in selection UI.
969
+ {
970
+ id: "openclaw",
971
+ name: "OpenClaw",
972
+ tier: "Rules, skills",
973
+ hidden: true,
974
+ detect: () => fs.existsSync(path.join(os.homedir(), ".openclaw")) || cmdExists("openclaw"),
975
+ },
976
+ {
977
+ id: "roo-code",
978
+ name: "Roo Code",
979
+ tier: "Rules, skills",
980
+ hidden: true,
981
+ detect: () => globDirExists(path.join(os.homedir(), ".vscode", "extensions"), "rooveterinaryinc.roo-cline-*"),
982
+ },
983
+ ];
984
+
985
+ // ─── Utility functions ──────────────────────────────────────────────────────
986
+ function cmdExists(cmd) {
987
+ try {
988
+ execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, { stdio: "ignore" });
989
+ return true;
990
+ } catch {
991
+ return false;
992
+ }
993
+ }
994
+
995
+ function globDirExists(dir, pattern) {
996
+ if (!fs.existsSync(dir)) return false;
997
+ const prefix = pattern.replace("*", "");
998
+ try {
999
+ return fs.readdirSync(dir).some((e) => e.startsWith(prefix));
1000
+ } catch {
1001
+ return false;
1002
+ }
1003
+ }
1004
+
1005
+ let linkFallbackWarned = false;
1006
+ function linkOrCopy(src, dest) {
1007
+ try {
1008
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1009
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
1010
+ fs.linkSync(src, dest);
1011
+ return "linked";
1012
+ } catch {
1013
+ try {
1014
+ fs.copyFileSync(src, dest);
1015
+ if (!linkFallbackWarned) {
1016
+ console.log(`\n ${dim("Note: Hard links unavailable — using copies. Edits won't auto-propagate.")}`);
1017
+ console.log(` ${dim("Run link.sh after editing source files to re-sync.")}\n`);
1018
+ linkFallbackWarned = true;
1019
+ }
1020
+ return "copied";
1021
+ } catch {
1022
+ return null;
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ function copyDirRecursive(src, dest) {
1028
+ fs.mkdirSync(dest, { recursive: true });
1029
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
1030
+ const s = path.join(src, entry.name);
1031
+ const d = path.join(dest, entry.name);
1032
+ if (entry.isDirectory()) {
1033
+ copyDirRecursive(s, d);
1034
+ } else {
1035
+ fs.copyFileSync(s, d);
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ // ─── Skill categories (maps skill folder name → category) ───────────────────
1041
+ const SKILL_CATEGORIES = {
1042
+ // Development
1043
+ "agent-code-reviewer": "development",
1044
+ "agent-security-auditor": "development",
1045
+ "systematic-debugging": "development",
1046
+ "find-bugs": "development",
1047
+ "refactoring": "development",
1048
+ "react-best-practices": "development",
1049
+ "webhook-handler-patterns": "development",
1050
+ // Marketing
1051
+ "copywriting": "marketing",
1052
+ "copy-editing": "marketing",
1053
+ "seo-audit": "marketing",
1054
+ "seo-content-writer": "marketing",
1055
+ "social-content": "marketing",
1056
+ "email-sequence": "marketing",
1057
+ "paid-ads": "marketing",
1058
+ "analytics-tracking": "marketing",
1059
+ "human-writer": "marketing",
1060
+ "marketing-ideas": "marketing",
1061
+ "marketing-psychology": "marketing",
1062
+ "ab-test-setup": "marketing",
1063
+ // CRO
1064
+ "page-cro": "cro",
1065
+ "form-cro": "cro",
1066
+ "signup-flow-cro": "cro",
1067
+ "onboarding-cro": "cro",
1068
+ "popup-cro": "cro",
1069
+ "paywall-upgrade-cro": "cro",
1070
+ // Strategy
1071
+ "pricing-strategy": "strategy",
1072
+ "launch-strategy": "strategy",
1073
+ "competitor-alternatives": "strategy",
1074
+ "free-tool-strategy": "strategy",
1075
+ "referral-program": "strategy",
1076
+ "agent-strategy-analyst": "strategy",
1077
+ "agent-research-analyst": "strategy",
1078
+ "agent-content-researcher": "strategy",
1079
+ // SEO
1080
+ "programmatic-seo": "seo",
1081
+ "schema-markup": "seo",
1082
+ // Design
1083
+ "frontend-design": "design",
1084
+ "ui-ux-pro-max": "design",
1085
+ // Obsidian
1086
+ "obsidian-markdown": "obsidian",
1087
+ "obsidian-bases": "obsidian",
1088
+ "obsidian-cli": "obsidian",
1089
+ "json-canvas": "obsidian",
1090
+ // Tools (always installed — core utilities)
1091
+ "defuddle": "tools",
1092
+ "skill-creator": "tools",
1093
+ "find-skills": "tools",
1094
+ };
1095
+
1096
+ const CATEGORY_META = [
1097
+ { id: "development", name: "Development", desc: "code review, debugging, security" },
1098
+ { id: "marketing", name: "Marketing", desc: "copy, SEO, social, email, ads" },
1099
+ { id: "cro", name: "CRO", desc: "page optimization, forms, popups" },
1100
+ { id: "strategy", name: "Strategy", desc: "pricing, launch, competitors" },
1101
+ { id: "seo", name: "SEO", desc: "programmatic SEO, schema markup" },
1102
+ { id: "design", name: "Design", desc: "frontend, UI/UX" },
1103
+ { id: "obsidian", name: "Obsidian", desc: "markdown, bases, canvas, CLI" },
1104
+ ];
1105
+
1106
+ function findSkills(bundleDir) {
1107
+ const skillsDir = path.join(bundleDir, "src", "skills");
1108
+ if (!fs.existsSync(skillsDir)) return [];
1109
+ const skills = [];
1110
+ const walk = (dir) => {
1111
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1112
+ const full = path.join(dir, entry.name);
1113
+ if (entry.isDirectory()) {
1114
+ if (fs.existsSync(path.join(full, "SKILL.md"))) {
1115
+ skills.push({ name: entry.name, path: full, category: SKILL_CATEGORIES[entry.name] || "tools" });
1116
+ } else {
1117
+ walk(full);
1118
+ }
1119
+ }
1120
+ }
1121
+ };
1122
+ walk(skillsDir);
1123
+ return skills;
1124
+ }
1125
+
1126
+ // ─── AGENTS.md generator ────────────────────────────────────────────────────
1127
+ function generateAgentsMd() {
1128
+ return `# AGENTS.md
1129
+
1130
+ > Mover OS — The Agentic Operating System for Obsidian
1131
+
1132
+ ## What This Is
1133
+
1134
+ You are operating within **Mover OS**, an AI-powered execution engine built on Obsidian. You audit behavior against stated strategy, extract reusable knowledge, and evolve via user corrections.
1135
+
1136
+ ## Core Rules
1137
+
1138
+ 1. **Read before acting.** Load Engine files (02_Areas/Engine/) before any strategic operation.
1139
+ 2. **Cite sources.** Every claim needs \`[file:line]\` or \`[INFERRED: reason]\`. Confidence < 3 = ask.
1140
+ 3. **Append-only.** Never delete from plan.md, Auto_Learnings.md, or Daily Notes.
1141
+ 4. **Grounded output.** No fabricated answers. "I don't know" beats a guess.
1142
+ 5. **Engine protection.** Never overwrite Identity_Prime.md, Strategy.md, or other Engine files without explicit approval.
1143
+
1144
+ ## Project Structure
1145
+
1146
+ \`\`\`
1147
+ 00_Inbox/ # Quick capture
1148
+ 01_Projects/ # Active projects with plan.md + project_state.md
1149
+ 02_Areas/Engine/ # THE CORE — Identity, Strategy, Context, Dossier
1150
+ 03_Library/ # Permanent knowledge (MOCs, SOPs, Principles)
1151
+ 04_Archives/ # Completed projects
1152
+ \`\`\`
1153
+
1154
+ ## Engine Files (Priority Order)
1155
+
1156
+ | Priority | File | Purpose |
1157
+ |----------|------|---------|
1158
+ | P0 | Auto_Learnings.md | AI corrections & behavioral patterns |
1159
+ | P1 | Active_Context.md | Current state, blockers, energy |
1160
+ | P2 | Strategy.md | Business/life hypothesis being tested |
1161
+ | P3 | Identity_Prime.md | User persona, values, anti-identity |
1162
+ | P4 | project_state.md | Project-specific knowledge |
1163
+ | P5 | Yesterday's Daily Note | Recent session history |
1164
+
1165
+ ## Behavior
1166
+
1167
+ - Be direct. No hedging, no "Great question!", no emdashes.
1168
+ - Think like a co-founder, not a consultant.
1169
+ - When wrong, name it, fix it, move on.
1170
+ - Comment WHY, not WHAT.
1171
+ - Have opinions. "It depends" is a cop-out.
1172
+
1173
+ ## Workflows
1174
+
1175
+ Available slash commands: /setup, /walkthrough, /plan-tomorrow, /analyse-day, /log, /review-week, /morning, /overview, /ignite, /harvest, /debug-resistance, /pivot-strategy, /reboot, /refactor-plan
1176
+
1177
+ Each workflow hands off to the next. Follow the breadcrumbs.
1178
+
1179
+ ## Daily Flow
1180
+
1181
+ \`\`\`
1182
+ /morning → [WORK] → /log → /analyse-day → /plan-tomorrow
1183
+ Sunday: /review-week first
1184
+ New project: /ignite → [WORK]
1185
+ Stuck: /debug-resistance
1186
+ \`\`\`
1187
+ `;
1188
+ }
1189
+
1190
+ // ─── SOUL.md generator (OpenClaw) ───────────────────────────────────────────
1191
+ function generateSoulMd() {
1192
+ return `# SOUL.md — Mover OS
1193
+
1194
+ > You are not a chatbot. You are a co-founder who happens to live inside a terminal.
1195
+
1196
+ ## Core Truths
1197
+
1198
+ - Be genuinely helpful, not performatively helpful. If the answer is "don't build that," say it.
1199
+ - Have opinions. "It depends" is a cop-out. Pick a direction, defend it, change your mind if the user makes a better case.
1200
+ - Be resourceful before asking. Exhaust what you can figure out, then ask sharp questions — not broad ones.
1201
+ - Earn trust through competence, not compliance. Nobody respects a yes-man.
1202
+
1203
+ ## The Vibe
1204
+
1205
+ - Talk like a co-founder who's been in the trenches, not a consultant billing by the hour.
1206
+ - One sharp sentence beats three careful ones. Say what you mean.
1207
+ - When you're wrong, don't grovel. Name it, fix it, move. Apologies waste both your time.
1208
+ - Match intensity to stakes. Casual for small tasks. Locked in for architecture. Blunt for bad ideas.
1209
+
1210
+ ## Boundaries
1211
+
1212
+ - Never hedge to avoid being wrong. Pick a position.
1213
+ - Never pretend all approaches are equally valid when one is clearly better.
1214
+ - Never pad output to look thorough. Substance only.
1215
+ - Never be artificially enthusiastic about bad ideas.
1216
+
1217
+ ## Continuity
1218
+
1219
+ - You start fresh each session but the files are your memory. Read them, trust them, build on them.
1220
+ - If something changed between sessions and you don't understand why, ask — don't assume.
1221
+ - The user corrects you to make you better. Take corrections seriously and encode them.
1222
+ `;
1223
+ }
1224
+
1225
+ // ─── Claude Code hooks (settings.json) ──────────────────────────────────────
1226
+ function generateClaudeSettings() {
1227
+ return JSON.stringify(
1228
+ {
1229
+ hooks: {
1230
+ Stop: [
1231
+ {
1232
+ hooks: [
1233
+ { type: "command", command: 'bash "$HOME/.claude/hooks/session-log-reminder.sh"', timeout: 10 },
1234
+ { type: "command", command: 'bash "$HOME/.claude/hooks/dirty-tree-guard.sh"', timeout: 10 },
1235
+ { type: "command", command: 'bash "$HOME/.claude/hooks/context-staleness.sh"', timeout: 5 },
1236
+ ],
1237
+ },
1238
+ ],
1239
+ PreToolUse: [
1240
+ {
1241
+ matcher: "Write|Edit",
1242
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/engine-protection.sh"', timeout: 5 }],
1243
+ },
1244
+ {
1245
+ matcher: "Bash",
1246
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/git-safety.sh"', timeout: 5 }],
1247
+ },
1248
+ ],
1249
+ PostToolUse: [
1250
+ {
1251
+ matcher: "Write|Edit",
1252
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/plan-sync-reminder.sh"', timeout: 5 }],
1253
+ },
1254
+ ],
1255
+ PreCompact: [
1256
+ {
1257
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/pre-compact-backup.sh"', timeout: 15 }],
1258
+ },
1259
+ ],
1260
+ SessionEnd: [
1261
+ {
1262
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-end.sh"', timeout: 10 }],
1263
+ },
1264
+ ],
1265
+ PermissionRequest: [
1266
+ {
1267
+ matcher: "Write|Edit",
1268
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/engine-permission-request.sh"', timeout: 5 }],
1269
+ },
1270
+ ],
1271
+ SessionStart: [
1272
+ {
1273
+ matcher: "startup|clear",
1274
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-start.sh" full', timeout: 5 }],
1275
+ },
1276
+ {
1277
+ matcher: "compact",
1278
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-start.sh" volatile', timeout: 5 }],
1279
+ },
1280
+ ],
1281
+ ConfigChange: [
1282
+ {
1283
+ matcher: "user_settings",
1284
+ hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/config-guard.sh"', timeout: 5 }],
1285
+ },
1286
+ ],
1287
+ },
1288
+ },
1289
+ null,
1290
+ 2
1291
+ );
1292
+ }
1293
+
1294
+ // ─── .gitignore ─────────────────────────────────────────────────────────────
1295
+ function generateGitignore() {
1296
+ return `# Mover OS — protected from git
1297
+ 02_Areas/Engine/Dailies/
1298
+ 02_Areas/Engine/Weekly Reviews/
1299
+ .obsidian/
1300
+ .trash/
1301
+ dev/
1302
+ `;
1303
+ }
1304
+
1305
+ // ─── Install functions ──────────────────────────────────────────────────────
1306
+ function createVaultStructure(vaultPath) {
1307
+ const dirs = [
1308
+ "00_Inbox",
1309
+ "01_Projects",
1310
+ "01_Projects/_Template Project",
1311
+ "02_Areas/Engine",
1312
+ "02_Areas/Engine/Dailies",
1313
+ "02_Areas/Engine/Weekly Reviews",
1314
+ "03_Library/Cheatsheets",
1315
+ "03_Library/Inputs",
1316
+ "03_Library/MOCs",
1317
+ "03_Library/Principles",
1318
+ "03_Library/Scripts",
1319
+ "03_Library/SOPs",
1320
+ "04_Archives",
1321
+ "Templates",
1322
+ ];
1323
+
1324
+ let created = 0;
1325
+ for (const dir of dirs) {
1326
+ const full = path.join(vaultPath, dir);
1327
+ if (!fs.existsSync(full)) {
1328
+ fs.mkdirSync(full, { recursive: true });
1329
+ created++;
1330
+ }
1331
+ }
1332
+ return created;
1333
+ }
1334
+
1335
+ function writeMoverConfig(vaultPath, agentIds, licenseKey) {
1336
+ const configDir = path.join(os.homedir(), ".mover");
1337
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
1338
+ const configPath = path.join(configDir, "config.json");
1339
+ const config = {
1340
+ vaultPath: vaultPath,
1341
+ agents: agentIds,
1342
+ feedbackWebhook: "https://moveros.dev/api/feedback",
1343
+ installedAt: new Date().toISOString(),
1344
+ };
1345
+ if (licenseKey) config.licenseKey = licenseKey;
1346
+ // If config exists, preserve installedAt and licenseKey from original install
1347
+ if (fs.existsSync(configPath)) {
1348
+ try {
1349
+ const existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
1350
+ if (existing.installedAt) config.installedAt = existing.installedAt;
1351
+ if (existing.licenseKey && !licenseKey) config.licenseKey = existing.licenseKey;
1352
+ config.updatedAt = new Date().toISOString();
1353
+ } catch {}
1354
+ }
1355
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
1356
+ return configPath;
1357
+ }
1358
+
1359
+ function installTemplateFiles(bundleDir, vaultPath) {
1360
+ const structDir = path.join(bundleDir, "src", "structure");
1361
+ if (!fs.existsSync(structDir)) return 0;
1362
+
1363
+ let count = 0;
1364
+ const walk = (dir, rel) => {
1365
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1366
+ const srcPath = path.join(dir, entry.name);
1367
+ const destPath = path.join(vaultPath, rel, entry.name);
1368
+
1369
+ if (entry.isDirectory()) {
1370
+ walk(srcPath, path.join(rel, entry.name));
1371
+ } else {
1372
+ const relNorm = rel.replace(/\\/g, "/");
1373
+ if (relNorm.includes("02_Areas") && relNorm.includes("Engine") && fs.existsSync(destPath)) {
1374
+ continue;
1375
+ }
1376
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
1377
+ if (!fs.existsSync(destPath)) {
1378
+ fs.copyFileSync(srcPath, destPath);
1379
+ count++;
1380
+ }
1381
+ }
1382
+ }
1383
+ };
1384
+ walk(structDir, "");
1385
+ return count;
1386
+ }
1387
+
1388
+ function installWorkflows(bundleDir, destDir, selectedWorkflows) {
1389
+ const srcDir = path.join(bundleDir, "src", "workflows");
1390
+ if (!fs.existsSync(srcDir)) return 0;
1391
+
1392
+ fs.mkdirSync(destDir, { recursive: true });
1393
+ const srcFiles = new Set(fs.readdirSync(srcDir).filter((f) => f.endsWith(".md")));
1394
+ let count = 0;
1395
+ for (const file of srcFiles) {
1396
+ if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
1397
+ if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) count++;
1398
+ }
1399
+
1400
+ // Clean orphaned workflows (renamed/removed in updates)
1401
+ // Only removes files that contain Mover OS markers — preserves user-created commands
1402
+ for (const file of fs.readdirSync(destDir).filter((f) => f.endsWith(".md"))) {
1403
+ if (!srcFiles.has(file)) {
1404
+ try {
1405
+ const content = fs.readFileSync(path.join(destDir, file), "utf8");
1406
+ if (content.includes("Risk Tier:") || content.includes("**Trigger:** User runs")) {
1407
+ fs.unlinkSync(path.join(destDir, file));
1408
+ ln(` ${dim("Removed orphan:")} ${file}`);
1409
+ }
1410
+ } catch (e) { /* skip unreadable files */ }
1411
+ }
1412
+ }
1413
+ return count;
1414
+ }
1415
+
1416
+ // ─── Rules Diff-Merge ────────────────────────────────────────────────────────
1417
+ // During updates, preserve user customizations appended below the sentinel.
1418
+ // Sentinel: <!-- MOVER_OS_RULES_END
1419
+ // Fallback: ## My Customizations
1420
+
1421
+ const RULES_SENTINEL = "<!-- MOVER_OS_RULES_END";
1422
+ const CUSTOM_HEADER = "## My Customizations";
1423
+
1424
+ function extractCustomizations(filePath) {
1425
+ if (!fs.existsSync(filePath)) return null;
1426
+ const content = fs.readFileSync(filePath, "utf8");
1427
+
1428
+ // Check for sentinel first
1429
+ const sentinelIdx = content.indexOf(RULES_SENTINEL);
1430
+ if (sentinelIdx !== -1) {
1431
+ const lineEnd = content.indexOf("\n", sentinelIdx);
1432
+ if (lineEnd !== -1) {
1433
+ const customs = content.substring(lineEnd + 1).trim();
1434
+ return customs.length > 0 ? customs : null;
1435
+ }
1436
+ }
1437
+
1438
+ // Fallback: look for ## My Customizations header
1439
+ const customIdx = content.indexOf(CUSTOM_HEADER);
1440
+ if (customIdx !== -1) {
1441
+ const customs = content.substring(customIdx).trim();
1442
+ return customs.length > 0 ? customs : null;
1443
+ }
1444
+
1445
+ return null;
1446
+ }
1447
+
1448
+ function installRules(bundleDir, destPath, agentId) {
1449
+ const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1450
+ if (!fs.existsSync(src)) return false;
1451
+
1452
+ // Extract any user customizations from existing file before overwriting
1453
+ const customizations = extractCustomizations(destPath);
1454
+
1455
+ if (customizations) {
1456
+ const chars = customizations.length;
1457
+ ln(` ${yellow("!")} Preserving ${chars.toLocaleString()} chars of user customizations`);
1458
+ }
1459
+
1460
+ // Claude Code gets a hard link (live updates). Other agents get an adapted copy.
1461
+ if (!agentId || agentId === "claude-code") {
1462
+ if (customizations) {
1463
+ // Can't hard-link when user has customizations (shared inode = shared content).
1464
+ // Copy source, then append customizations.
1465
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
1466
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
1467
+ let content = fs.readFileSync(src, "utf8");
1468
+ if (!content.includes(RULES_SENTINEL)) {
1469
+ content += `\n${RULES_SENTINEL} — Everything below this line is preserved during updates -->\n`;
1470
+ }
1471
+ content += "\n" + customizations + "\n";
1472
+ fs.writeFileSync(destPath, content, "utf8");
1473
+
1474
+ // Size warning
1475
+ if (content.length > 8000) {
1476
+ ln(` ${yellow("!")} Rules file is ${content.length.toLocaleString()} chars (>${dim("8k")}). Large files may degrade AI attention to later sections.`);
1477
+ }
1478
+ return true;
1479
+ }
1480
+ return linkOrCopy(src, destPath) !== null;
1481
+ }
1482
+
1483
+ // Adapted rules: strip Claude-specific sections for other agents
1484
+ let content = fs.readFileSync(src, "utf8");
1485
+
1486
+ // Strip S10.5 Subagents (Task tool is Claude-specific)
1487
+ content = content.replace(/## 10\.5 Subagents[\s\S]*?(?=\n---\n)/m, "");
1488
+
1489
+ // Strip Pre-Flight Active_Context hook reference (hooks are Claude-specific)
1490
+ // Keep the vault path and Active_Context loading instructions — those are universal
1491
+
1492
+ // Add agent-specific header
1493
+ const agentHeaders = {
1494
+ "cursor": "# Cursor Project Rules",
1495
+ "windsurf": "# Windsurf Rules",
1496
+ "gemini-cli": "# Gemini CLI Rules",
1497
+ "antigravity": "# Antigravity Rules",
1498
+ "copilot": "# Copilot Instructions",
1499
+ };
1500
+
1501
+ if (agentHeaders[agentId]) {
1502
+ content = content.replace(/^# Mover OS Global Rules/m, agentHeaders[agentId]);
1503
+ }
1504
+
1505
+ // Append preserved customizations for non-Claude agents too
1506
+ if (customizations) {
1507
+ if (!content.includes(RULES_SENTINEL)) {
1508
+ content += `\n${RULES_SENTINEL} — Everything below this line is preserved during updates -->\n`;
1509
+ }
1510
+ content += "\n" + customizations + "\n";
1511
+ }
1512
+
1513
+ // Size warning
1514
+ if (content.length > 8000) {
1515
+ ln(` ${yellow("!")} Rules file is ${content.length.toLocaleString()} chars (>${dim("8k")}). Large files may degrade AI attention to later sections.`);
1516
+ }
1517
+
1518
+ fs.writeFileSync(destPath, content, "utf8");
1519
+ return true;
1520
+ }
1521
+
1522
+ function installSkillPacks(bundleDir, destDir, selectedCategories) {
1523
+ const skills = findSkills(bundleDir);
1524
+ fs.mkdirSync(destDir, { recursive: true });
1525
+ const installedNames = new Set();
1526
+ let count = 0;
1527
+ for (const skill of skills) {
1528
+ // Filter by category if categories were selected (tools always installed)
1529
+ if (selectedCategories && skill.category !== "tools" && !selectedCategories.has(skill.category)) continue;
1530
+ const dest = path.join(destDir, skill.name);
1531
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
1532
+ copyDirRecursive(skill.path, dest);
1533
+ installedNames.add(skill.name);
1534
+ count++;
1535
+ }
1536
+
1537
+ // Clean orphaned skills (renamed/removed in updates)
1538
+ // Only removes dirs that contain SKILL.md — preserves user-created skills
1539
+ for (const dir of fs.readdirSync(destDir)) {
1540
+ if (installedNames.has(dir)) continue;
1541
+ const dirPath = path.join(destDir, dir);
1542
+ try {
1543
+ if (fs.statSync(dirPath).isDirectory() && fs.existsSync(path.join(dirPath, "SKILL.md"))) {
1544
+ const content = fs.readFileSync(path.join(dirPath, "SKILL.md"), "utf8");
1545
+ if (content.includes("## Activation") || content.includes("## When to Use")) {
1546
+ fs.rmSync(dirPath, { recursive: true, force: true });
1547
+ ln(` ${dim("Removed orphan skill:")} ${dir}`);
1548
+ }
1549
+ }
1550
+ } catch (e) { /* skip */ }
1551
+ }
1552
+ return count;
1553
+ }
1554
+
1555
+ function installHooksForClaude(bundleDir, vaultPath) {
1556
+ const hooksSrc = path.join(bundleDir, "src", "hooks");
1557
+ const hooksDst = path.join(os.homedir(), ".claude", "hooks");
1558
+ // Write hooks to GLOBAL settings (~/.claude/) not project settings ({vault}/.claude/).
1559
+ // Project settings should stay empty to prevent double-fire.
1560
+ const settingsDir = path.join(os.homedir(), ".claude");
1561
+
1562
+ if (!fs.existsSync(hooksSrc)) return 0;
1563
+
1564
+ fs.mkdirSync(hooksDst, { recursive: true });
1565
+ fs.mkdirSync(settingsDir, { recursive: true });
1566
+
1567
+ let count = 0;
1568
+ for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".sh"))) {
1569
+ const dst = path.join(hooksDst, file);
1570
+ // Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
1571
+ const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1572
+ fs.writeFileSync(dst, content, { mode: 0o755 });
1573
+ count++;
1574
+ }
1575
+ // Copy hook template files (.md) and scripts (.js)
1576
+ for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".md") || f.endsWith(".js"))) {
1577
+ const dst = path.join(hooksDst, file);
1578
+ fs.copyFileSync(path.join(hooksSrc, file), dst);
1579
+ count++;
1580
+ }
1581
+
1582
+ // Deep-merge hooks into existing settings.json (preserve user config)
1583
+ const settingsPath = path.join(settingsDir, "settings.json");
1584
+ const newHooks = JSON.parse(generateClaudeSettings()).hooks;
1585
+ if (fs.existsSync(settingsPath)) {
1586
+ try {
1587
+ const existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
1588
+ if (!existing.hooks) existing.hooks = {};
1589
+ // Merge each hook event (Stop, PreToolUse, PreCompact)
1590
+ for (const [event, entries] of Object.entries(newHooks)) {
1591
+ if (!existing.hooks[event]) {
1592
+ existing.hooks[event] = entries;
1593
+ } else {
1594
+ // Check if our hooks are already registered (by command substring)
1595
+ const existingCmds = JSON.stringify(existing.hooks[event]);
1596
+ const alreadyHas = entries[0].hooks.every(
1597
+ (h) => existingCmds.includes(h.command.split("/").pop().replace('"', ""))
1598
+ );
1599
+ if (!alreadyHas) {
1600
+ existing.hooks[event].push(...entries);
1601
+ }
1602
+ }
1603
+ }
1604
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8");
1605
+ } catch {
1606
+ // Corrupt settings — backup before overwrite
1607
+ const backupPath = settingsPath + ".bak";
1608
+ try { fs.copyFileSync(settingsPath, backupPath); } catch {}
1609
+ ln(` ${yellow("!")} Settings file corrupt — backed up to settings.json.bak, writing fresh`);
1610
+ fs.writeFileSync(settingsPath, generateClaudeSettings(), "utf8");
1611
+ }
1612
+ } else {
1613
+ fs.writeFileSync(settingsPath, generateClaudeSettings(), "utf8");
1614
+ }
1615
+
1616
+ return count;
1617
+ }
1618
+
1619
+ // ─── Per-agent install orchestrators ────────────────────────────────────────
1620
+ function installClaudeCode(bundleDir, vaultPath, skillOpts) {
1621
+ const home = os.homedir();
1622
+ const steps = [];
1623
+
1624
+ const claudeDir = path.join(home, ".claude");
1625
+ fs.mkdirSync(claudeDir, { recursive: true });
1626
+ if (!skillOpts?.skipRules) {
1627
+ if (installRules(bundleDir, path.join(claudeDir, "CLAUDE.md"), "claude-code")) steps.push("rules");
1628
+ }
1629
+
1630
+ const cmdsDir = path.join(claudeDir, "commands");
1631
+ const wfCount = installWorkflows(bundleDir, cmdsDir, skillOpts?.workflows);
1632
+ if (wfCount > 0) steps.push(`${wfCount} commands`);
1633
+
1634
+ if (skillOpts && skillOpts.install) {
1635
+ const skillsDir = path.join(claudeDir, "skills");
1636
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1637
+ if (skCount > 0) steps.push(`${skCount} skills`);
1638
+ }
1639
+
1640
+ if (vaultPath && !skillOpts?.skipHooks) {
1641
+ const hkCount = installHooksForClaude(bundleDir, vaultPath);
1642
+ if (hkCount > 0) steps.push(`${hkCount} hooks`);
1643
+ }
1644
+
1645
+ // Status line
1646
+ if (skillOpts && skillOpts.statusLine) {
1647
+ const statusSrc = path.join(bundleDir, "src", "hooks", "statusline.js");
1648
+ const statusDst = path.join(home, ".claude", "statusline.js");
1649
+ if (fs.existsSync(statusSrc)) {
1650
+ fs.copyFileSync(statusSrc, statusDst);
1651
+ const globalSettings = path.join(home, ".claude", "settings.json");
1652
+ try {
1653
+ const settings = fs.existsSync(globalSettings)
1654
+ ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
1655
+ : {};
1656
+ settings.statusLine = { type: "command", command: "node ~/.claude/statusline.js" };
1657
+ fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
1658
+ } catch {
1659
+ fs.writeFileSync(
1660
+ globalSettings,
1661
+ JSON.stringify({ statusLine: { type: "command", command: "node ~/.claude/statusline.js" } }, null, 2),
1662
+ "utf8"
1663
+ );
1664
+ }
1665
+ steps.push("status line");
1666
+ }
1667
+ }
1668
+
1669
+ return steps;
1670
+ }
1671
+
1672
+ function installCursor(bundleDir, vaultPath, skillOpts) {
1673
+ const home = os.homedir();
1674
+ const steps = [];
1675
+
1676
+ if (!skillOpts?.skipRules) {
1677
+ if (vaultPath) {
1678
+ if (installRules(bundleDir, path.join(vaultPath, ".cursorrules"), "cursor")) steps.push("rules");
1679
+ }
1680
+
1681
+ const cursorRulesDir = path.join(home, ".cursor", "rules");
1682
+ fs.mkdirSync(cursorRulesDir, { recursive: true });
1683
+ const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1684
+ if (fs.existsSync(src)) {
1685
+ linkOrCopy(src, path.join(cursorRulesDir, "mover-os.mdc"));
1686
+ }
1687
+ }
1688
+
1689
+ const cmdsDir = path.join(home, ".cursor", "commands");
1690
+ const wfCount = installWorkflows(bundleDir, cmdsDir, skillOpts?.workflows);
1691
+ if (wfCount > 0) steps.push(`${wfCount} commands`);
1692
+
1693
+ if (skillOpts && skillOpts.install) {
1694
+ const skillsDir = path.join(home, ".cursor", "skills");
1695
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1696
+ if (skCount > 0) steps.push(`${skCount} skills`);
1697
+ }
1698
+
1699
+ return steps;
1700
+ }
1701
+
1702
+ function installCline(bundleDir, vaultPath, skillOpts) {
1703
+ const steps = [];
1704
+
1705
+ if (vaultPath) {
1706
+ const rulesDir = path.join(vaultPath, ".clinerules");
1707
+ fs.mkdirSync(rulesDir, { recursive: true });
1708
+ const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1709
+ if (fs.existsSync(src)) {
1710
+ linkOrCopy(src, path.join(rulesDir, "mover-os.md"));
1711
+ steps.push("rules");
1712
+ }
1713
+
1714
+ if (skillOpts && skillOpts.install) {
1715
+ const skillsDir = path.join(vaultPath, ".cline", "skills");
1716
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1717
+ if (skCount > 0) steps.push(`${skCount} skills`);
1718
+ }
1719
+ }
1720
+
1721
+ return steps;
1722
+ }
1723
+
1724
+ function installCodex(bundleDir, vaultPath, skillOpts) {
1725
+ const home = os.homedir();
1726
+ const steps = [];
1727
+
1728
+ const codexDir = path.join(home, ".codex");
1729
+ fs.mkdirSync(codexDir, { recursive: true });
1730
+ // Codex CLI uses AGENTS.md content, not raw Global Rules (too large, wrong format)
1731
+ fs.writeFileSync(path.join(codexDir, "instructions.md"), generateAgentsMd(), "utf8");
1732
+ steps.push("rules");
1733
+
1734
+ if (skillOpts && skillOpts.install) {
1735
+ const skillsDir = path.join(codexDir, "skills");
1736
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1737
+ if (skCount > 0) steps.push(`${skCount} skills`);
1738
+ }
1739
+
1740
+ return steps;
1741
+ }
1742
+
1743
+ function installWindsurf(bundleDir, vaultPath, skillOpts) {
1744
+ const home = os.homedir();
1745
+ const steps = [];
1746
+ if (vaultPath) {
1747
+ if (installRules(bundleDir, path.join(vaultPath, ".windsurfrules"), "windsurf")) steps.push("rules");
1748
+ }
1749
+
1750
+ if (skillOpts && skillOpts.install) {
1751
+ const skillsDir = path.join(home, ".windsurf", "skills");
1752
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1753
+ if (skCount > 0) steps.push(`${skCount} skills`);
1754
+ }
1755
+
1756
+ return steps;
1757
+ }
1758
+
1759
+ function installOpenClaw(bundleDir, vaultPath, skillOpts) {
1760
+ const home = os.homedir();
1761
+ const workspace = path.join(home, ".openclaw", "workspace");
1762
+ const steps = [];
1763
+
1764
+ fs.mkdirSync(path.join(workspace, "skills"), { recursive: true });
1765
+ fs.mkdirSync(path.join(workspace, "memory"), { recursive: true });
1766
+
1767
+ fs.writeFileSync(path.join(workspace, "AGENTS.md"), generateAgentsMd(), "utf8");
1768
+ steps.push("AGENTS.md");
1769
+
1770
+ fs.writeFileSync(path.join(workspace, "SOUL.md"), generateSoulMd(), "utf8");
1771
+ steps.push("SOUL.md");
1772
+
1773
+ const userMd = path.join(workspace, "USER.md");
1774
+ if (!fs.existsSync(userMd)) {
1775
+ fs.writeFileSync(
1776
+ userMd,
1777
+ `# USER.md\n\n> Run \`/setup\` in Mover OS to populate this file with your Identity and Strategy.\n\n## Identity\n<!-- Populated by /setup -->\n\n## Strategy\n<!-- Populated by /setup -->\n\n## Assets\n<!-- Populated by /setup -->\n`,
1778
+ "utf8"
1779
+ );
1780
+ steps.push("USER.md");
1781
+ }
1782
+
1783
+ if (skillOpts && skillOpts.install) {
1784
+ const skCount = installSkillPacks(bundleDir, path.join(workspace, "skills"), skillOpts.categories);
1785
+ if (skCount > 0) steps.push(`${skCount} skills`);
1786
+ }
1787
+
1788
+ return steps;
1789
+ }
1790
+
1791
+ function installGeminiCli(bundleDir, vaultPath, skillOpts, writtenFiles) {
1792
+ const home = os.homedir();
1793
+ const steps = [];
1794
+
1795
+ const geminiDir = path.join(home, ".gemini");
1796
+ fs.mkdirSync(geminiDir, { recursive: true });
1797
+ const geminiMdPath = path.join(geminiDir, "GEMINI.md");
1798
+ if (!writtenFiles.has(geminiMdPath)) {
1799
+ if (installRules(bundleDir, geminiMdPath, "gemini-cli")) steps.push("rules");
1800
+ writtenFiles.add(geminiMdPath);
1801
+ } else {
1802
+ steps.push("rules (shared)");
1803
+ }
1804
+
1805
+ if (skillOpts && skillOpts.install) {
1806
+ const skCount = installSkillPacks(bundleDir, path.join(geminiDir, "skills"), skillOpts.categories);
1807
+ if (skCount > 0) steps.push(`${skCount} skills`);
1808
+ }
1809
+
1810
+ return steps;
1811
+ }
1812
+
1813
+ function installAntigravity(bundleDir, vaultPath, skillOpts, writtenFiles) {
1814
+ const home = os.homedir();
1815
+ const steps = [];
1816
+
1817
+ const geminiDir = path.join(home, ".gemini");
1818
+ fs.mkdirSync(geminiDir, { recursive: true });
1819
+ const geminiMdPath = path.join(geminiDir, "GEMINI.md");
1820
+ if (!writtenFiles.has(geminiMdPath)) {
1821
+ if (installRules(bundleDir, geminiMdPath, "antigravity")) steps.push("rules");
1822
+ writtenFiles.add(geminiMdPath);
1823
+ } else {
1824
+ steps.push("rules (shared)");
1825
+ }
1826
+
1827
+ const wfDir = path.join(geminiDir, "antigravity", "global_workflows");
1828
+ const wfCount = installWorkflows(bundleDir, wfDir, skillOpts?.workflows);
1829
+ if (wfCount > 0) steps.push(`${wfCount} workflows`);
1830
+
1831
+ if (skillOpts && skillOpts.install) {
1832
+ const skillsDir = path.join(geminiDir, "antigravity", "skills");
1833
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1834
+ if (skCount > 0) steps.push(`${skCount} skills`);
1835
+ }
1836
+
1837
+ return steps;
1838
+ }
1839
+
1840
+ function installRooCode(bundleDir, vaultPath, skillOpts) {
1841
+ const steps = [];
1842
+ if (vaultPath) {
1843
+ const rulesDir = path.join(vaultPath, ".roo", "rules");
1844
+ fs.mkdirSync(rulesDir, { recursive: true });
1845
+ const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1846
+ if (fs.existsSync(src)) {
1847
+ fs.copyFileSync(src, path.join(rulesDir, "mover-os.md"));
1848
+ steps.push("rules");
1849
+ }
1850
+
1851
+ if (skillOpts && skillOpts.install) {
1852
+ const skillsDir = path.join(vaultPath, ".roo", "skills");
1853
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1854
+ if (skCount > 0) steps.push(`${skCount} skills`);
1855
+ }
1856
+ }
1857
+ return steps;
1858
+ }
1859
+
1860
+ function installCopilot(bundleDir, vaultPath, skillOpts) {
1861
+ const steps = [];
1862
+ if (vaultPath) {
1863
+ const ghDir = path.join(vaultPath, ".github");
1864
+ fs.mkdirSync(ghDir, { recursive: true });
1865
+ const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
1866
+ if (fs.existsSync(src)) {
1867
+ fs.copyFileSync(src, path.join(ghDir, "copilot-instructions.md"));
1868
+ steps.push("rules");
1869
+ }
1870
+
1871
+ if (skillOpts && skillOpts.install) {
1872
+ const skillsDir = path.join(ghDir, "skills");
1873
+ const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
1874
+ if (skCount > 0) steps.push(`${skCount} skills`);
1875
+ }
1876
+ }
1877
+ return steps;
1878
+ }
1879
+
1880
+ const AGENT_INSTALLERS = {
1881
+ "claude-code": installClaudeCode,
1882
+ cursor: installCursor,
1883
+ cline: installCline,
1884
+ codex: installCodex,
1885
+ windsurf: installWindsurf,
1886
+ openclaw: installOpenClaw,
1887
+ "gemini-cli": installGeminiCli,
1888
+ antigravity: installAntigravity,
1889
+ "roo-code": installRooCode,
1890
+ copilot: installCopilot,
1891
+ };
1892
+
1893
+ // ─── Pre-flight checks ──────────────────────────────────────────────────────
1894
+ function preflight() {
1895
+ const issues = [];
1896
+
1897
+ // Node version
1898
+ const nodeVer = parseInt(process.versions.node.split(".")[0], 10);
1899
+ if (nodeVer < 18) {
1900
+ issues.push({ label: "Node.js", status: "fail", detail: `v${process.versions.node} (need 18+)` });
1901
+ } else {
1902
+ issues.push({ label: "Node.js", status: "ok", detail: `v${process.versions.node}` });
1903
+ }
1904
+
1905
+ // Git
1906
+ const hasGit = cmdExists("git");
1907
+ issues.push({ label: "git", status: hasGit ? "ok" : "warn", detail: hasGit ? "found" : "not found (optional)" });
1908
+
1909
+ // OS
1910
+ const plat = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux";
1911
+ issues.push({ label: "Platform", status: "ok", detail: plat });
1912
+
1913
+ // Obsidian — check common install locations
1914
+ let hasObsidian = false;
1915
+ if (process.platform === "darwin") {
1916
+ hasObsidian = fs.existsSync("/Applications/Obsidian.app");
1917
+ } else if (process.platform === "win32") {
1918
+ hasObsidian = fs.existsSync(path.join(os.homedir(), "AppData", "Local", "Obsidian"));
1919
+ } else {
1920
+ hasObsidian = cmdExists("obsidian") || fs.existsSync(path.join(os.homedir(), ".config", "obsidian"));
1921
+ }
1922
+ issues.push({
1923
+ label: "Obsidian",
1924
+ status: hasObsidian ? "ok" : "warn",
1925
+ detail: hasObsidian ? "found" : "not found — get it at obsidian.md",
1926
+ });
1927
+
1928
+ return issues;
1929
+ }
1930
+
1931
+ // ─── Main ───────────────────────────────────────────────────────────────────
1932
+ async function main() {
1933
+ const opts = parseArgs();
1934
+ let bundleDir = path.resolve(__dirname);
1935
+ const startTime = Date.now();
1936
+
1937
+ // ── Intro ──
1938
+ printHeader();
1939
+
1940
+ // ── Pre-flight ──
1941
+ barLn(gray("Pre-flight"));
1942
+ barLn();
1943
+ const checks = preflight();
1944
+ for (const c of checks) {
1945
+ const icon = c.status === "ok" ? green("\u2713") : c.status === "warn" ? yellow("\u25CB") : red("\u2717");
1946
+ barLn(`${icon} ${dim(`${c.label} ${c.detail}`)}`);
1947
+ }
1948
+ barLn();
1949
+
1950
+ if (checks.some((c) => c.status === "fail")) {
1951
+ outro(red("Pre-flight failed. Fix the issues above."));
1952
+ process.exit(1);
1953
+ }
1954
+
1955
+ // ── Headless quick update (--update flag) ──
1956
+ if (opts.update) {
1957
+ // Validate stored key
1958
+ let updateKey = opts.key;
1959
+ if (!updateKey) {
1960
+ const cfgPath = path.join(os.homedir(), ".mover", "config.json");
1961
+ if (fs.existsSync(cfgPath)) {
1962
+ try { updateKey = JSON.parse(fs.readFileSync(cfgPath, "utf8")).licenseKey; } catch {}
1963
+ }
1964
+ }
1965
+ if (!updateKey || !await validateKey(updateKey)) {
1966
+ outro(red("Valid license key required. Use: npx moveros --update --key YOUR_KEY"));
1967
+ process.exit(1);
1968
+ }
1969
+
1970
+ // Download payload if not bundled
1971
+ const hasSrcUpdate = fs.existsSync(path.join(bundleDir, "src", "workflows"));
1972
+ if (!hasSrcUpdate) {
1973
+ barLn(dim("Downloading payload..."));
1974
+ try {
1975
+ bundleDir = await downloadPayload(updateKey);
1976
+ } catch (err) {
1977
+ outro(red(`Download failed: ${err.message}`));
1978
+ process.exit(1);
1979
+ }
1980
+ }
1981
+
1982
+ // Auto-detect vault
1983
+ let vaultPath = opts.vault;
1984
+ if (!vaultPath) {
1985
+ const obsVaults = detectObsidianVaults();
1986
+ vaultPath = obsVaults.find((p) =>
1987
+ fs.existsSync(path.join(p, ".mover-version"))
1988
+ );
1989
+ if (!vaultPath) {
1990
+ outro(red("No Mover OS vault found. Use: npx moveros --update --vault /path"));
1991
+ process.exit(1);
1992
+ }
1993
+ }
1994
+ if (vaultPath.startsWith("~")) vaultPath = path.join(os.homedir(), vaultPath.slice(1));
1995
+ vaultPath = path.resolve(vaultPath);
1996
+ barLn(dim(`Vault: ${vaultPath}`));
1997
+
1998
+ // Auto-detect agents
1999
+ const detectedAgents = AGENTS.filter((a) => a.detect());
2000
+ if (detectedAgents.length === 0) {
2001
+ outro(red("No AI agents detected."));
2002
+ process.exit(1);
2003
+ }
2004
+ const selectedIds = detectedAgents.map((a) => a.id);
2005
+ barLn(dim(`Agents: ${detectedAgents.map((a) => a.name).join(", ")}`));
2006
+ barLn();
2007
+
2008
+ // Detect changes
2009
+ const changes = detectChanges(bundleDir, vaultPath, selectedIds);
2010
+ const totalChanged = countChanges(changes);
2011
+
2012
+ // Read versions
2013
+ const vfPath = path.join(vaultPath, ".mover-version");
2014
+ const installedVer = fs.existsSync(vfPath) ? fs.readFileSync(vfPath, "utf8").trim() : null;
2015
+ let newVer = `V${VERSION}`;
2016
+ try {
2017
+ const pkg = JSON.parse(fs.readFileSync(path.join(bundleDir, "package.json"), "utf8"));
2018
+ newVer = pkg.version || newVer;
2019
+ } catch {}
2020
+
2021
+ displayChangeSummary(changes, installedVer, newVer);
2022
+
2023
+ if (totalChanged === 0) {
2024
+ outro(green("Already up to date."));
2025
+ return;
2026
+ }
2027
+
2028
+ // Apply all changes
2029
+ barLn(bold("Updating..."));
2030
+ barLn();
2031
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2032
+
2033
+ // Vault structure
2034
+ createVaultStructure(vaultPath);
2035
+
2036
+ // Templates
2037
+ installTemplateFiles(bundleDir, vaultPath);
2038
+
2039
+ // Per-agent installation
2040
+ const writtenFiles = new Set();
2041
+ const skillOpts = { install: true, categories: null, workflows: null };
2042
+ for (const agent of detectedAgents) {
2043
+ const fn = AGENT_INSTALLERS[agent.id];
2044
+ if (!fn) continue;
2045
+ const sp = spinner(agent.name);
2046
+ const usesWrittenFiles = agent.id === "antigravity" || agent.id === "gemini-cli";
2047
+ const steps = usesWrittenFiles
2048
+ ? fn(bundleDir, vaultPath, skillOpts, writtenFiles)
2049
+ : fn(bundleDir, vaultPath, skillOpts);
2050
+ await sleep(200);
2051
+ if (steps.length > 0) {
2052
+ sp.stop(`${agent.name} ${dim(steps.join(", "))}`);
2053
+ } else {
2054
+ sp.stop(`${agent.name} ${dim("configured")}`);
2055
+ }
2056
+ }
2057
+
2058
+ // Update version marker + config
2059
+ fs.writeFileSync(path.join(vaultPath, ".mover-version"), `V${VERSION}\n`, "utf8");
2060
+ writeMoverConfig(vaultPath, selectedIds);
2061
+
2062
+ barLn();
2063
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2064
+ outro(`${green("Done.")} ${totalChanged} files updated in ${elapsed}s. Run ${bold("/update")} if version bumped.`);
2065
+ return;
2066
+ }
2067
+
2068
+ // ── License key ──
2069
+ let key = opts.key;
2070
+
2071
+ // Check stored key from previous install
2072
+ if (!key) {
2073
+ const configPath = path.join(os.homedir(), ".mover", "config.json");
2074
+ if (fs.existsSync(configPath)) {
2075
+ try {
2076
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
2077
+ if (cfg.licenseKey) key = cfg.licenseKey;
2078
+ } catch {}
2079
+ }
2080
+ }
2081
+
2082
+ if (!key) {
2083
+ let attempts = 0;
2084
+ while (attempts < 3) {
2085
+ key = await textInput({
2086
+ label: "Enter your license key",
2087
+ mask: "\u25AA",
2088
+ placeholder: "MOVER-XXXX-XXXX",
2089
+ });
2090
+
2091
+ const sp = spinner("Validating...");
2092
+ const valid = await validateKey(key);
2093
+ if (valid) {
2094
+ sp.stop(green("License verified"));
2095
+ await activateKey(key);
2096
+ break;
2097
+ }
2098
+
2099
+ sp.stop(red("Invalid key"));
2100
+ attempts++;
2101
+ if (attempts < 3) {
2102
+ barLn(red("Try again."));
2103
+ barLn();
2104
+ key = "";
2105
+ }
2106
+ }
2107
+
2108
+ if (!await validateKey(key)) {
2109
+ barLn(red("Invalid license key."));
2110
+ barLn();
2111
+ barLn(dim("Get a key at https://moveros.dev"));
2112
+ outro("Cancelled.");
2113
+ process.exit(1);
2114
+ }
2115
+ } else {
2116
+ const sp = spinner("Validating license...");
2117
+ if (!await validateKey(key)) {
2118
+ sp.stop(red("Invalid key"));
2119
+ barLn();
2120
+ barLn(dim("Get a key at https://moveros.dev"));
2121
+ outro("Cancelled.");
2122
+ process.exit(1);
2123
+ }
2124
+ sp.stop(green("License verified"));
2125
+ await activateKey(key);
2126
+ barLn();
2127
+ }
2128
+
2129
+ // ── Download payload ──
2130
+ const hasSrc = fs.existsSync(path.join(bundleDir, "src", "workflows"));
2131
+ if (!hasSrc) {
2132
+ const dlSp = spinner("Downloading Mover OS...");
2133
+ try {
2134
+ bundleDir = await downloadPayload(key);
2135
+ dlSp.stop(green("Downloaded"));
2136
+ } catch (err) {
2137
+ dlSp.stop(red("Download failed"));
2138
+ barLn(red(err.message));
2139
+ barLn();
2140
+ barLn(dim("Check your connection and try again."));
2141
+ outro("Cancelled.");
2142
+ process.exit(1);
2143
+ }
2144
+ barLn();
2145
+ }
2146
+
2147
+ // ── Vault path ──
2148
+ let vaultPath = opts.vault;
2149
+
2150
+ if (!vaultPath) {
2151
+ const obsVaults = detectObsidianVaults();
2152
+
2153
+ if (obsVaults.length > 0) {
2154
+ question("Select your Obsidian vault");
2155
+ barLn();
2156
+
2157
+ const vaultItems = obsVaults.map((p) => {
2158
+ const name = path.basename(p);
2159
+ const hasMover =
2160
+ fs.existsSync(path.join(p, ".mover-version")) ||
2161
+ fs.existsSync(path.join(p, "02_Areas", "Engine"));
2162
+ return {
2163
+ id: p,
2164
+ name: `${name}${hasMover ? dim(" (Mover OS)") : ""}`,
2165
+ tier: dim(p),
2166
+ };
2167
+ });
2168
+ vaultItems.push({
2169
+ id: "__manual__",
2170
+ name: "Enter path manually",
2171
+ tier: "Type or paste a custom vault path",
2172
+ });
2173
+
2174
+ const selected = await interactiveSelect(vaultItems, { multi: false });
2175
+
2176
+ if (selected === "__manual__") {
2177
+ vaultPath = await textInput({
2178
+ label: "Where is your Obsidian vault?",
2179
+ initial: path.join(os.homedir(), "Mover-OS"),
2180
+ });
2181
+ } else {
2182
+ vaultPath = selected;
2183
+ }
2184
+ } else {
2185
+ vaultPath = await textInput({
2186
+ label: "Where is your Obsidian vault?",
2187
+ initial: path.join(os.homedir(), "Mover-OS"),
2188
+ });
2189
+ }
2190
+ } else {
2191
+ barLn(dim(`Vault: ${vaultPath}`));
2192
+ barLn();
2193
+ }
2194
+
2195
+ if (vaultPath.startsWith("~")) vaultPath = path.join(os.homedir(), vaultPath.slice(1));
2196
+ vaultPath = path.resolve(vaultPath);
2197
+
2198
+ // ── Detect existing install → show mode menu ──
2199
+ const engine = detectEngineFiles(vaultPath);
2200
+ const versionFile = path.join(vaultPath, ".mover-version");
2201
+ const hasExistingInstall = fs.existsSync(versionFile) || engine.exists;
2202
+
2203
+ let installMode = "fresh"; // fresh | update | uninstall
2204
+
2205
+ if (hasExistingInstall) {
2206
+ if (engine.exists) {
2207
+ barLn(yellow("Existing Mover OS vault detected."));
2208
+ barLn(dim(` Engine files: ${engine.files.join(", ")}`));
2209
+ } else {
2210
+ barLn(yellow("Mover OS installed, but no Engine data yet."));
2211
+ }
2212
+ barLn();
2213
+
2214
+ question("What would you like to do?");
2215
+ barLn();
2216
+
2217
+ installMode = await interactiveSelect(
2218
+ [
2219
+ { id: "update", name: "Update", tier: "Refreshes rules, commands, and skills. Your data stays safe." },
2220
+ { id: "fresh", name: "Fresh Install", tier: "Full setup from scratch. Template files will be overwritten." },
2221
+ { id: "uninstall", name: "Uninstall", tier: "Remove Mover OS files from your agents and vault." },
2222
+ ],
2223
+ { multi: false, defaultIndex: 0 }
2224
+ );
2225
+ }
2226
+
2227
+ // ── Uninstall flow ──
2228
+ if (installMode === "uninstall") {
2229
+ await runUninstall(vaultPath);
2230
+ return;
2231
+ }
2232
+
2233
+ const updateMode = installMode === "update";
2234
+
2235
+ // ── Backup (update mode only, if Engine files exist) ──
2236
+ if (updateMode && engine.exists) {
2237
+ barLn();
2238
+ question("Back up before updating?");
2239
+ barLn(dim(" Select what to save. Your data won't be overwritten, but backups are always safer."));
2240
+ barLn();
2241
+
2242
+ const backupItems = [
2243
+ { id: "engine", name: "Engine files", tier: "Identity, Strategy, Goals, and all Engine data" },
2244
+ ];
2245
+
2246
+ const areasDir = path.join(vaultPath, "02_Areas");
2247
+ if (fs.existsSync(areasDir)) {
2248
+ backupItems.push({ id: "areas", name: "Full Areas folder", tier: "Everything in 02_Areas/ including Dailies and Reviews" });
2249
+ }
2250
+
2251
+ // Only offer agent config backup if any agents are detected
2252
+ const detectedForBackup = AGENTS.filter((a) => a.detect()).map((a) => a.id);
2253
+ if (detectedForBackup.length > 0) {
2254
+ backupItems.push({ id: "agents", name: "Agent configs", tier: `Current rules, skills, and commands from ${detectedForBackup.length} detected agent(s)` });
2255
+ }
2256
+
2257
+ backupItems.push({ id: "skip", name: "Skip backup", tier: "Continue without backing up" });
2258
+
2259
+ const backupChoices = await interactiveSelect(backupItems, {
2260
+ multi: true,
2261
+ preSelected: ["engine"],
2262
+ });
2263
+
2264
+ if (backupChoices.length > 0 && !(backupChoices.length === 1 && backupChoices.includes("skip"))) {
2265
+ const now = new Date();
2266
+ 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")}`;
2267
+ const archivesDir = path.join(vaultPath, "04_Archives");
2268
+
2269
+ // Engine files backup
2270
+ if (backupChoices.includes("engine")) {
2271
+ const backupDir = path.join(archivesDir, `Engine_Backup_${ts}`);
2272
+ const engineDir = path.join(vaultPath, "02_Areas", "Engine");
2273
+ try {
2274
+ fs.mkdirSync(backupDir, { recursive: true });
2275
+ let backed = 0;
2276
+ for (const file of fs.readdirSync(engineDir)) {
2277
+ const src = path.join(engineDir, file);
2278
+ if (fs.statSync(src).isFile()) {
2279
+ fs.copyFileSync(src, path.join(backupDir, file));
2280
+ backed++;
2281
+ }
2282
+ }
2283
+ barLn(green(` Backed up ${backed} Engine files to 04_Archives/Engine_Backup_${ts}/`));
2284
+ } catch (err) {
2285
+ barLn(yellow(` Engine backup failed: ${err.message}. Continuing anyway.`));
2286
+ }
2287
+ }
2288
+
2289
+ // Full Areas folder backup
2290
+ if (backupChoices.includes("areas")) {
2291
+ const backupDir = path.join(archivesDir, `Areas_Backup_${ts}`);
2292
+ try {
2293
+ copyDirRecursive(path.join(vaultPath, "02_Areas"), backupDir);
2294
+ barLn(green(` Backed up full Areas folder to 04_Archives/Areas_Backup_${ts}/`));
2295
+ } catch (err) {
2296
+ barLn(yellow(` Areas backup failed: ${err.message}. Continuing anyway.`));
2297
+ }
2298
+ }
2299
+
2300
+ // Agent configs backup
2301
+ if (backupChoices.includes("agents")) {
2302
+ const home = os.homedir();
2303
+ const agentBackupDir = path.join(archivesDir, `Agent_Backup_${ts}`);
2304
+ const AGENT_CONFIG_PATHS = {
2305
+ "claude-code": [
2306
+ { src: path.join(home, ".claude", "CLAUDE.md"), label: "CLAUDE.md" },
2307
+ { src: path.join(home, ".claude", "commands"), label: "commands" },
2308
+ { src: path.join(home, ".claude", "skills"), label: "skills" },
2309
+ { src: path.join(home, ".claude", "hooks"), label: "hooks" },
2310
+ ],
2311
+ cursor: [
2312
+ { src: path.join(home, ".cursor", "rules"), label: "rules" },
2313
+ { src: path.join(home, ".cursor", "commands"), label: "commands" },
2314
+ { src: path.join(home, ".cursor", "skills"), label: "skills" },
2315
+ { src: path.join(vaultPath, ".cursorrules"), label: ".cursorrules" },
2316
+ ],
2317
+ cline: [
2318
+ { src: path.join(vaultPath, ".clinerules"), label: ".clinerules" },
2319
+ { src: path.join(vaultPath, ".cline", "skills"), label: "skills" },
2320
+ ],
2321
+ windsurf: [
2322
+ { src: path.join(vaultPath, ".windsurfrules"), label: ".windsurfrules" },
2323
+ { src: path.join(home, ".windsurf", "skills"), label: "skills" },
2324
+ ],
2325
+ "gemini-cli": [
2326
+ { src: path.join(home, ".gemini", "GEMINI.md"), label: "GEMINI.md" },
2327
+ { src: path.join(home, ".gemini", "skills"), label: "skills" },
2328
+ ],
2329
+ antigravity: [
2330
+ { src: path.join(home, ".gemini", "GEMINI.md"), label: "GEMINI.md" },
2331
+ { src: path.join(home, ".gemini", "antigravity", "global_workflows"), label: "workflows" },
2332
+ { src: path.join(home, ".gemini", "antigravity", "skills"), label: "skills" },
2333
+ ],
2334
+ copilot: [
2335
+ { src: path.join(vaultPath, ".github", "copilot-instructions.md"), label: "copilot-instructions.md" },
2336
+ { src: path.join(vaultPath, ".github", "skills"), label: "skills" },
2337
+ ],
2338
+ codex: [
2339
+ { src: path.join(home, ".codex", "instructions.md"), label: "instructions.md" },
2340
+ { src: path.join(home, ".codex", "skills"), label: "skills" },
2341
+ ],
2342
+ openclaw: [
2343
+ { src: path.join(home, ".openclaw", "workspace"), label: "workspace" },
2344
+ ],
2345
+ "roo-code": [
2346
+ { src: path.join(vaultPath, ".roo", "rules"), label: "rules" },
2347
+ { src: path.join(vaultPath, ".roo", "skills"), label: "skills" },
2348
+ ],
2349
+ };
2350
+
2351
+ let agentsBacked = 0;
2352
+ for (const agentId of detectedForBackup) {
2353
+ const paths = AGENT_CONFIG_PATHS[agentId];
2354
+ if (!paths) continue;
2355
+ const agentDir = path.join(agentBackupDir, agentId);
2356
+ let hasContent = false;
2357
+ for (const { src, label } of paths) {
2358
+ try {
2359
+ if (!fs.existsSync(src)) continue;
2360
+ const stat = fs.statSync(src);
2361
+ if (stat.isDirectory()) {
2362
+ copyDirRecursive(src, path.join(agentDir, label));
2363
+ hasContent = true;
2364
+ } else {
2365
+ fs.mkdirSync(agentDir, { recursive: true });
2366
+ fs.copyFileSync(src, path.join(agentDir, label));
2367
+ hasContent = true;
2368
+ }
2369
+ } catch { /* skip inaccessible paths */ }
2370
+ }
2371
+ if (hasContent) agentsBacked++;
2372
+ }
2373
+ if (agentsBacked > 0) {
2374
+ barLn(green(` Backed up configs from ${agentsBacked} agent(s) to 04_Archives/Agent_Backup_${ts}/`));
2375
+ }
2376
+ }
2377
+ }
2378
+ }
2379
+
2380
+ if (!fs.existsSync(vaultPath)) fs.mkdirSync(vaultPath, { recursive: true });
2381
+
2382
+ // ── Agent selection ──
2383
+ const visibleAgents = AGENTS.filter((a) => !a.hidden);
2384
+ const detectedIds = visibleAgents.filter((a) => a.detect()).map((a) => a.id);
2385
+ const agentItems = visibleAgents.map((a) => ({
2386
+ ...a,
2387
+ _detected: detectedIds.includes(a.id),
2388
+ }));
2389
+
2390
+ if (detectedIds.length === 0) {
2391
+ barLn(yellow("No AI agents detected."));
2392
+ barLn(dim("You need at least one to use Mover OS. Recommended: Claude Code (claude.ai/code)"));
2393
+ barLn();
2394
+ }
2395
+ question(`Select your AI agents${detectedIds.length > 0 ? dim(" (detected agents pre-selected)") : ""}`);
2396
+ barLn();
2397
+
2398
+ const selectedIds = await interactiveSelect(agentItems, {
2399
+ multi: true,
2400
+ preSelected: detectedIds,
2401
+ });
2402
+ const selectedAgents = AGENTS.filter((a) => selectedIds.includes(a.id));
2403
+
2404
+ if (selectedAgents.length === 0) {
2405
+ barLn(yellow("No agents selected."));
2406
+ outro("Cancelled.");
2407
+ process.exit(0);
2408
+ }
2409
+
2410
+ // ── Change detection + selection (update mode only) ──
2411
+ let selectedWorkflows = null; // null = install all
2412
+ let skipHooks = false;
2413
+ let skipRules = false;
2414
+ let skipTemplates = false;
2415
+
2416
+ if (updateMode) {
2417
+ const changes = detectChanges(bundleDir, vaultPath, selectedIds);
2418
+ const totalChanged = countChanges(changes);
2419
+
2420
+ // Read versions for display
2421
+ const versionFilePath = path.join(vaultPath, ".mover-version");
2422
+ const installedVersion = fs.existsSync(versionFilePath)
2423
+ ? fs.readFileSync(versionFilePath, "utf8").trim()
2424
+ : null;
2425
+ let newVersion = `V${VERSION}`;
2426
+ try {
2427
+ const pkgPath = path.join(bundleDir, "package.json");
2428
+ if (fs.existsSync(pkgPath)) {
2429
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
2430
+ newVersion = pkg.version || newVersion;
2431
+ }
2432
+ } catch {}
2433
+
2434
+ barLn();
2435
+ question("Change Summary");
2436
+ barLn();
2437
+ displayChangeSummary(changes, installedVersion, newVersion);
2438
+
2439
+ if (totalChanged === 0) {
2440
+ barLn(green(" Already up to date."));
2441
+ barLn();
2442
+ } else {
2443
+ const applyChoice = await interactiveSelect(
2444
+ [
2445
+ { id: "all", name: "Yes, update all changed files", tier: "" },
2446
+ { id: "select", name: "Select individually", tier: "" },
2447
+ { id: "cancel", name: "Cancel", tier: "" },
2448
+ ],
2449
+ { multi: false, defaultIndex: 0 }
2450
+ );
2451
+
2452
+ if (applyChoice === "cancel") {
2453
+ outro("Cancelled.");
2454
+ process.exit(0);
2455
+ }
2456
+
2457
+ if (applyChoice === "select") {
2458
+ // Build list of only changed/new files for individual selection
2459
+ const changedItems = [];
2460
+ const changedPreSelected = [];
2461
+ for (const f of changes.workflows.filter((x) => x.status !== "unchanged")) {
2462
+ const id = `wf:${f.file}`;
2463
+ changedItems.push({ id, name: `/${f.file.replace(".md", "")}`, tier: dim(f.status === "new" ? "new workflow" : "workflow") });
2464
+ changedPreSelected.push(id);
2465
+ }
2466
+ for (const f of changes.hooks.filter((x) => x.status !== "unchanged")) {
2467
+ const id = `hook:${f.file}`;
2468
+ changedItems.push({ id, name: f.file, tier: dim(f.status === "new" ? "new hook" : "hook") });
2469
+ changedPreSelected.push(id);
2470
+ }
2471
+ if (changes.rules === "changed") {
2472
+ changedItems.push({ id: "rules", name: "Global Rules", tier: dim("rules") });
2473
+ changedPreSelected.push("rules");
2474
+ }
2475
+ for (const f of changes.templates.filter((x) => x.status !== "unchanged")) {
2476
+ const id = `tmpl:${f.file}`;
2477
+ changedItems.push({ id, name: f.file.replace(/\\/g, "/"), tier: dim(f.status === "new" ? "new template" : "template") });
2478
+ changedPreSelected.push(id);
2479
+ }
2480
+
2481
+ if (changedItems.length > 0) {
2482
+ question("Select files to update");
2483
+ barLn();
2484
+ const selectedFileIds = await interactiveSelect(changedItems, {
2485
+ multi: true,
2486
+ preSelected: changedPreSelected,
2487
+ });
2488
+
2489
+ // Build workflow filter Set
2490
+ const selectedWfFiles = selectedFileIds
2491
+ .filter((id) => id.startsWith("wf:"))
2492
+ .map((id) => id.slice(3));
2493
+ if (selectedWfFiles.length < changes.workflows.filter((x) => x.status !== "unchanged").length) {
2494
+ selectedWorkflows = new Set(selectedWfFiles);
2495
+ }
2496
+ // Check if hooks/rules/templates were deselected
2497
+ skipHooks = !selectedFileIds.some((id) => id.startsWith("hook:"));
2498
+ skipRules = !selectedFileIds.includes("rules");
2499
+ skipTemplates = !selectedFileIds.some((id) => id.startsWith("tmpl:"));
2500
+ }
2501
+ }
2502
+ // "all" = selectedWorkflows stays null, skip flags stay false
2503
+ }
2504
+ }
2505
+
2506
+ // ── Skills ──
2507
+ const allSkills = findSkills(bundleDir);
2508
+ let installSkills = false;
2509
+ let selectedCategories = null; // null = all, Set = filtered
2510
+
2511
+ if (allSkills.length > 0 && selectedAgents.some((a) => a.id !== "aider")) {
2512
+ // Count skills per category
2513
+ const catCounts = {};
2514
+ for (const sk of allSkills) {
2515
+ catCounts[sk.category] = (catCounts[sk.category] || 0) + 1;
2516
+ }
2517
+
2518
+ question(`${bold(String(allSkills.length))} skill packs available. Select categories:`);
2519
+ barLn();
2520
+
2521
+ const categoryItems = CATEGORY_META.map((c) => ({
2522
+ id: c.id,
2523
+ name: `${c.name} ${dim(`(${catCounts[c.id] || 0})`)}`,
2524
+ tier: dim(c.desc),
2525
+ }));
2526
+
2527
+ // Pre-select Development + Obsidian (core categories everyone needs)
2528
+ const preSelected = ["development", "obsidian"];
2529
+
2530
+ const selectedCatIds = await interactiveSelect(categoryItems, {
2531
+ multi: true,
2532
+ preSelected,
2533
+ });
2534
+
2535
+ installSkills = selectedCatIds.length > 0;
2536
+ if (installSkills) {
2537
+ selectedCategories = new Set(selectedCatIds);
2538
+ // Count how many skills will be installed (selected categories + tools)
2539
+ const skillCount = allSkills.filter((s) => s.category === "tools" || selectedCategories.has(s.category)).length;
2540
+ barLn(dim(`${skillCount} skills selected`));
2541
+ }
2542
+ }
2543
+
2544
+ // ── Status line (Claude Code only) ──
2545
+ let installStatusLine = false;
2546
+ if (selectedIds.includes("claude-code")) {
2547
+ barLn();
2548
+ question("Install Claude Code status line?");
2549
+ barLn(dim(" Shows model, project, context usage, and session cost in your terminal."));
2550
+ barLn(dim(" Example: [Opus 4.6] my-project | Context: 42% | $3.50"));
2551
+ barLn();
2552
+
2553
+ const slChoice = await interactiveSelect(
2554
+ [
2555
+ { id: "yes", name: "Yes, install it", tier: "Adds a live status bar to every Claude Code session" },
2556
+ { id: "no", name: "No, skip", tier: "You can add it later in ~/.claude/settings.json" },
2557
+ ],
2558
+ { multi: false, defaultIndex: 0 }
2559
+ );
2560
+ installStatusLine = slChoice === "yes";
2561
+ }
2562
+
2563
+ // ── Install with animated spinners ──
2564
+ barLn();
2565
+ question(updateMode ? bold("Updating...") : bold("Installing..."));
2566
+ barLn();
2567
+
2568
+ let totalSteps = 0;
2569
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2570
+
2571
+ // 0. Legacy cleanup (remove files from older installer versions)
2572
+ const legacyPaths = [
2573
+ path.join(vaultPath, "src"), // hooks used to install here
2574
+ path.join(vaultPath, "SOUL.md"), // old soul file
2575
+ ];
2576
+ for (const lp of legacyPaths) {
2577
+ if (fs.existsSync(lp)) {
2578
+ try {
2579
+ const stat = fs.statSync(lp);
2580
+ if (stat.isDirectory()) {
2581
+ fs.rmSync(lp, { recursive: true, force: true });
2582
+ } else {
2583
+ fs.unlinkSync(lp);
2584
+ }
2585
+ } catch {}
2586
+ }
2587
+ }
2588
+
2589
+ // 1. Vault structure
2590
+ let sp = spinner("Vault structure");
2591
+ const dirsCreated = createVaultStructure(vaultPath);
2592
+ await sleep(200);
2593
+ sp.stop(`Vault structure${dirsCreated > 0 ? dim(` ${dirsCreated} folders`) : dim(" verified")}`);
2594
+ totalSteps++;
2595
+
2596
+ // 2. Template files (runs in both modes — only creates missing files, never overwrites)
2597
+ if (!skipTemplates) {
2598
+ sp = spinner(updateMode ? "New template files" : "Engine templates");
2599
+ const templatesInstalled = installTemplateFiles(bundleDir, vaultPath);
2600
+ await sleep(200);
2601
+ sp.stop(updateMode
2602
+ ? `New template files${templatesInstalled > 0 ? dim(` ${templatesInstalled} added`) : dim(" all present")}`
2603
+ : `Engine templates${templatesInstalled > 0 ? dim(` ${templatesInstalled} files`) : dim(" up to date")}`);
2604
+ totalSteps++;
2605
+ }
2606
+
2607
+ // 3. CLAUDE.md (skip on update)
2608
+ if (!updateMode) {
2609
+ const vaultClaudeMd = path.join(vaultPath, "CLAUDE.md");
2610
+ const bundleClaudeMd = path.join(bundleDir, "CLAUDE.md");
2611
+ if (!fs.existsSync(vaultClaudeMd) && fs.existsSync(bundleClaudeMd)) {
2612
+ fs.copyFileSync(bundleClaudeMd, vaultClaudeMd);
2613
+ sp = spinner("CLAUDE.md");
2614
+ await sleep(150);
2615
+ sp.stop("CLAUDE.md");
2616
+ totalSteps++;
2617
+ }
2618
+ }
2619
+
2620
+ // 4. Per-agent installation
2621
+ const writtenFiles = new Set(); // Track shared files to avoid double-writes (e.g. GEMINI.md)
2622
+ const skillOpts = { install: installSkills, categories: selectedCategories, statusLine: installStatusLine, workflows: selectedWorkflows, skipHooks, skipRules, skipTemplates };
2623
+ for (const agent of selectedAgents) {
2624
+ const fn = AGENT_INSTALLERS[agent.id];
2625
+ if (!fn) continue;
2626
+ sp = spinner(agent.name);
2627
+ const usesWrittenFiles = agent.id === "antigravity" || agent.id === "gemini-cli";
2628
+ const steps = usesWrittenFiles
2629
+ ? fn(bundleDir, vaultPath, skillOpts, writtenFiles)
2630
+ : fn(bundleDir, vaultPath, skillOpts);
2631
+ await sleep(250);
2632
+ if (steps.length > 0) {
2633
+ sp.stop(`${agent.name} ${dim(steps.join(", "))}`);
2634
+ totalSteps++;
2635
+ } else {
2636
+ sp.stop(`${agent.name} ${dim("configured")}`);
2637
+ totalSteps++;
2638
+ }
2639
+ }
2640
+
2641
+ // 5. AGENTS.md — only for agents that need it (consolidated write)
2642
+ const needsAgentsMd = selectedAgents.some((a) => ["codex"].includes(a.id));
2643
+ if (needsAgentsMd) {
2644
+ fs.writeFileSync(path.join(vaultPath, "AGENTS.md"), generateAgentsMd(), "utf8");
2645
+ sp = spinner("AGENTS.md");
2646
+ await sleep(150);
2647
+ sp.stop("AGENTS.md");
2648
+ totalSteps++;
2649
+ }
2650
+
2651
+ // 6. Obsidian theme (install CSS snippet)
2652
+ {
2653
+ const snippetsDir = path.join(vaultPath, ".obsidian", "snippets");
2654
+ const themeSrc = path.join(bundleDir, "src", "theme", "minimal-theme.css");
2655
+ const themeDst = path.join(snippetsDir, "minimal-theme.css");
2656
+ if (fs.existsSync(themeSrc)) {
2657
+ fs.mkdirSync(snippetsDir, { recursive: true });
2658
+ fs.copyFileSync(themeSrc, themeDst);
2659
+ sp = spinner("Obsidian theme");
2660
+ await sleep(150);
2661
+ sp.stop(`Obsidian theme ${dim("enable in Settings → Appearance → CSS snippets")}`);
2662
+ totalSteps++;
2663
+ }
2664
+ }
2665
+
2666
+ // 7. .gitignore + git init (fresh install only)
2667
+ if (!updateMode) {
2668
+ const hasGit = cmdExists("git");
2669
+ if (hasGit) {
2670
+ const gitignorePath = path.join(vaultPath, ".gitignore");
2671
+ if (!fs.existsSync(gitignorePath)) {
2672
+ fs.writeFileSync(gitignorePath, generateGitignore(), "utf8");
2673
+ sp = spinner(".gitignore");
2674
+ await sleep(100);
2675
+ sp.stop(".gitignore");
2676
+ totalSteps++;
2677
+ }
2678
+ if (!fs.existsSync(path.join(vaultPath, ".git"))) {
2679
+ sp = spinner("Git repository");
2680
+ try {
2681
+ execSync("git init", { cwd: vaultPath, stdio: "ignore" });
2682
+ execSync("git add 01_Projects 02_Areas/Engine 03_Library 04_Archives .gitignore .mover-version CLAUDE.md", { cwd: vaultPath, stdio: "ignore" });
2683
+ execSync('git commit -m "Initial commit — Mover OS v' + VERSION + '"', { cwd: vaultPath, stdio: "ignore" });
2684
+ await sleep(300);
2685
+ sp.stop("Git initialized");
2686
+ totalSteps++;
2687
+ } catch {
2688
+ sp.stop(dim("Git skipped"));
2689
+ }
2690
+ }
2691
+ }
2692
+ }
2693
+
2694
+ // 8. Version stamp (fresh install only — update mode lets /update workflow stamp after migrations)
2695
+ if (!updateMode) {
2696
+ fs.writeFileSync(path.join(vaultPath, ".mover-version"), `V${VERSION}\n`, "utf8");
2697
+ }
2698
+
2699
+ // 9. Write ~/.mover/config.json (both fresh + update)
2700
+ writeMoverConfig(vaultPath, selectedIds, key);
2701
+
2702
+ barLn();
2703
+
2704
+ // ── Done ──
2705
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2706
+ const verb = updateMode ? "updated" : "installed";
2707
+ outro(`${green("Done.")} Mover OS v${VERSION} ${verb}. ${dim(`${totalSteps} steps in ${elapsed}s`)}`);
2708
+
2709
+ // Size check on installed rules files
2710
+ const rulesFile = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
2711
+ if (fs.existsSync(rulesFile)) {
2712
+ const rulesSize = fs.statSync(rulesFile).size;
2713
+ const estimatedTokens = Math.round(rulesSize / 4);
2714
+ if (estimatedTokens > 12000) {
2715
+ ln(` ${yellow("Warning:")} Global rules file is ~${(estimatedTokens / 1000).toFixed(1)}k tokens.`);
2716
+ ln(` ${dim("Large rule files consume context window. Consider trimming unused sections.")}`);
2717
+ ln();
2718
+ }
2719
+ }
2720
+
2721
+ const agentNames = selectedAgents.map((a) => a.name);
2722
+
2723
+ // ── What you got ──
2724
+ ln(gray(" ─────────────────────────────────────────────"));
2725
+ ln();
2726
+ ln(` ${bold("What was installed")}`);
2727
+ ln();
2728
+ ln(` ${green("▸")} ${bold("22")} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
2729
+ ln(` ${green("▸")} ${bold("61")} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
2730
+ if (selectedIds.includes("claude-code")) {
2731
+ ln(` ${green("▸")} ${bold("6")} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
2732
+ }
2733
+ ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
2734
+ ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
2735
+ ln();
2736
+
2737
+ // ── Next steps ──
2738
+ ln(gray(" ─────────────────────────────────────────────"));
2739
+ ln();
2740
+ ln(` ${bold("Next steps")}`);
2741
+ ln();
2742
+ if (updateMode) {
2743
+ ln(` ${cyan("1")} Open the vault folder in your AI agent`);
2744
+ ln(` ${dim("Updated: " + agentNames.join(", "))}`);
2745
+ ln();
2746
+ ln(` ${cyan("2")} Run ${bold("/update")}`);
2747
+ ln(` ${dim("Syncs your Engine with the latest version")}`);
2748
+ } else {
2749
+ ln(` ${cyan("1")} Open your vault in ${bold("Obsidian")}`);
2750
+ ln(` ${dim("This is where you view and browse your files")}`);
2751
+ ln();
2752
+ ln(` ${cyan("2")} Open the vault folder in your AI agent`);
2753
+ ln(` ${dim("Installed: " + agentNames.join(", "))}`);
2754
+ ln();
2755
+ ln(` ${cyan("3")} Enable the Obsidian theme`);
2756
+ ln(` ${dim("Settings → Appearance → CSS snippets → minimal-theme")}`);
2757
+ ln();
2758
+ ln(` ${cyan("4")} Run ${bold("/setup")}`);
2759
+ ln(` ${dim("Builds your Identity, Strategy, and Goals")}`);
2760
+ ln();
2761
+ ln(gray(" ─────────────────────────────────────────────"));
2762
+ ln();
2763
+ ln(` ${dim("Obsidian = view your files")}`);
2764
+ ln(` ${dim("Your AI agent = where you work")}`);
2765
+ }
2766
+ ln();
2767
+ ln(` ${dim("/morning → [work] → /log → /analyse-day → /plan-tomorrow")}`);
2768
+ ln();
2769
+ }
2770
+
2771
+ main().catch((err) => {
2772
+ cleanup();
2773
+ console.error(red(`\n Error: ${err.message}`));
2774
+ process.exit(1);
2775
+ });