vibe-config-sync 0.2.3 → 0.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.
package/README.md CHANGED
@@ -21,6 +21,7 @@ When using AI coding tools on multiple machines, configurations like skills, com
21
21
  | `agents/` | Agent definitions |
22
22
  | `plugins/installed_plugins.json` | Plugin registry (machine paths stripped) |
23
23
  | `plugins/known_marketplaces.json` | Plugin marketplace sources (machine paths stripped) |
24
+ | `mcp-servers.json` | MCP server configs (extracted from `~/.claude.json`) |
24
25
 
25
26
  **Not synced** (machine-specific / ephemeral / large): `plugins/cache/`, `projects/`, `telemetry/`, `session-env/`, `plans/`, `todos/`, `debug/`, `history.jsonl`, etc.
26
27
 
@@ -82,11 +83,11 @@ vibe-sync pull
82
83
 
83
84
  ## How It Works
84
85
 
85
- - **Export** copies config files into `~/.vibe-sync/data/`, stripping machine-specific paths from plugin JSON files. Skills that are symlinks are resolved and their actual contents are copied.
86
+ - **Export** copies config files into `~/.vibe-sync/data/`, stripping machine-specific paths from plugin JSON files. Skills that are symlinks are resolved and their actual contents are copied. MCP server configurations are extracted from `~/.claude.json` and saved as `mcp-servers.json` (prompts for confirmation if `env` fields containing potential secrets are detected).
86
87
 
87
- - **Import** restores files from `~/.vibe-sync/data/` to `~/.claude/`, validating JSON structure before overwriting. Plugin JSON files are used as a manifest only (never copied directly) — the `claude` CLI installs plugins and writes correct registry files with local paths. Already-installed plugins are detected and skipped.
88
+ - **Import** restores files from `~/.vibe-sync/data/` to `~/.claude/`, validating JSON structure before overwriting. Plugin JSON files are used as a manifest only (never copied directly) — the `claude` CLI installs plugins and writes correct registry files with local paths. Already-installed plugins are detected and skipped. MCP servers are merged into `~/.claude.json` — only new servers are added; existing servers are never overwritten.
88
89
 
89
- - **Backup** is created automatically at `~/.vibe-sync/backups/claude/<timestamp>/` before every import. Use `vibe-sync restore` to list or recover from backups.
90
+ - **Backup** is created automatically at `~/.vibe-sync/backups/claude/<timestamp>/` before every import. Both `~/.claude/` contents and `~/.claude.json` are backed up. Use `vibe-sync restore` to list or recover from backups.
90
91
 
91
92
  ## Sync Strategy
92
93
 
@@ -118,6 +119,12 @@ In short: new content is added, existing content is overwritten, but nothing is
118
119
 
119
120
  Plugin JSON files (`installed_plugins.json`, `known_marketplaces.json`) are exported with machine-specific paths stripped. On import, they are **not** copied to `~/.claude/plugins/` — instead they serve as a manifest. The `claude` CLI is invoked to install each plugin, and it writes correct registry files with local paths as a side effect. Already-installed plugins (detected by `installPath`) are skipped to avoid redundant network operations.
120
121
 
122
+ ### MCP Servers
123
+
124
+ MCP server configurations live in `~/.claude.json` under the `mcpServers` key. On export, this section is extracted and saved as `mcp-servers.json` in the sync directory. If any server config contains `env` fields (which may hold secrets like API keys), the user is prompted before exporting.
125
+
126
+ On import, MCP servers are **additive only** — new servers from the sync repo are added to `~/.claude.json`, but existing servers with the same name are never overwritten. This preserves any machine-specific customizations (e.g., local paths, environment variables).
127
+
121
128
  ### What This Means in Practice
122
129
 
123
130
  - If both machines modify different configs and then sync, the machine that runs `import` last will lose its local changes (a timestamped backup exists at `~/.vibe-sync/backups/claude/` for manual recovery)
@@ -129,6 +136,7 @@ Plugin JSON files (`installed_plugins.json`, `known_marketplaces.json`) are expo
129
136
  | Variable | Description | Default |
130
137
  |----------|-------------|---------|
131
138
  | `CLAUDE_HOME` | Override Claude config directory | `~/.claude` |
139
+ | `CLAUDE_JSON` | Override Claude global config file (MCP servers) | `~/.claude.json` |
132
140
 
133
141
  ## License
134
142
 
package/README.zh-CN.md CHANGED
@@ -21,6 +21,7 @@
21
21
  | `agents/` | 代理定义 |
22
22
  | `plugins/installed_plugins.json` | 插件注册表(已剥离机器路径) |
23
23
  | `plugins/known_marketplaces.json` | 插件市场来源(已剥离机器路径) |
24
+ | `mcp-servers.json` | MCP 服务器配置(从 `~/.claude.json` 提取) |
24
25
 
25
26
  **不同步**(机器特定 / 临时 / 大文件):`plugins/cache/`、`projects/`、`telemetry/`、`session-env/`、`plans/`、`todos/`、`debug/`、`history.jsonl` 等。
26
27
 
@@ -82,11 +83,11 @@ vibe-sync pull
82
83
 
83
84
  ## 工作原理
84
85
 
85
- - **导出**:将配置文件复制到 `~/.vibe-sync/data/`,同时从插件 JSON 文件中剥离机器特定路径。符号链接的技能会被解析并复制其实际内容。
86
+ - **导出**:将配置文件复制到 `~/.vibe-sync/data/`,同时从插件 JSON 文件中剥离机器特定路径。符号链接的技能会被解析并复制其实际内容。MCP 服务器配置从 `~/.claude.json` 中提取并保存为 `mcp-servers.json`(如果检测到包含潜在密钥的 `env` 字段,会提示用户确认)。
86
87
 
87
- - **导入**:从 `~/.vibe-sync/data/` 恢复文件到 `~/.claude/`,在覆盖前验证 JSON 结构。插件 JSON 文件仅作为清单使用(不会直接复制)—— `claude` CLI 负责安装插件并写入包含本地路径的正确注册文件。已安装的插件会被检测并跳过。
88
+ - **导入**:从 `~/.vibe-sync/data/` 恢复文件到 `~/.claude/`,在覆盖前验证 JSON 结构。插件 JSON 文件仅作为清单使用(不会直接复制)—— `claude` CLI 负责安装插件并写入包含本地路径的正确注册文件。已安装的插件会被检测并跳过。MCP 服务器以增量方式合并到 `~/.claude.json`——仅添加新服务器,已存在的服务器不会被覆盖。
88
89
 
89
- - **备份**:每次导入前自动在 `~/.vibe-sync/backups/claude/<timestamp>/` 创建备份。使用 `vibe-sync restore` 列出或恢复备份。
90
+ - **备份**:每次导入前自动在 `~/.vibe-sync/backups/claude/<timestamp>/` 创建备份,同时备份 `~/.claude/` 内容和 `~/.claude.json`。使用 `vibe-sync restore` 列出或恢复备份。
90
91
 
91
92
  ## 同步策略
92
93
 
@@ -118,6 +119,12 @@ vibe-sync pull
118
119
 
119
120
  插件 JSON 文件(`installed_plugins.json`、`known_marketplaces.json`)在导出时会剥离机器特定路径。导入时**不会**复制到 `~/.claude/plugins/`——而是作为清单使用。`claude` CLI 被调用来安装每个插件,并在安装过程中写入包含本地路径的正确注册文件。已安装的插件(通过 `installPath` 检测)会被跳过,以避免冗余的网络操作。
120
121
 
122
+ ### MCP 服务器
123
+
124
+ MCP 服务器配置存储在 `~/.claude.json` 的 `mcpServers` 键下。导出时,该部分被提取并保存为同步目录中的 `mcp-servers.json`。如果任何服务器配置包含 `env` 字段(可能包含 API 密钥等密钥),导出前会提示用户确认。
125
+
126
+ 导入时,MCP 服务器采用**仅增量**策略——同步仓库中的新服务器会被添加到 `~/.claude.json`,但同名的已有服务器不会被覆盖。这保留了机器特定的自定义配置(如本地路径、环境变量)。
127
+
121
128
  ### 实际影响
122
129
 
123
130
  - 如果两台机器修改了不同的配置然后同步,最后执行 `import` 的机器会丢失其本地更改(可在 `~/.vibe-sync/backups/claude/` 找到带时间戳的备份进行手动恢复)
@@ -129,6 +136,7 @@ vibe-sync pull
129
136
  | 变量 | 说明 | 默认值 |
130
137
  |------|------|--------|
131
138
  | `CLAUDE_HOME` | 覆盖 Claude 配置目录 | `~/.claude` |
139
+ | `CLAUDE_JSON` | 覆盖 Claude 全局配置文件(MCP 服务器) | `~/.claude.json` |
132
140
 
133
141
  ## 许可证
134
142
 
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import fs from "fs-extra";
8
8
  import os from "os";
9
9
  import path from "path";
10
10
  var CLAUDE_HOME = process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude");
11
+ var CLAUDE_JSON = process.env.CLAUDE_JSON ?? path.join(os.homedir(), ".claude.json");
11
12
  var SYNC_DIR = path.join(os.homedir(), ".vibe-sync");
12
13
  var BACKUP_BASE = path.join(SYNC_DIR, "backups", "claude");
13
14
  function getConfigDir() {
@@ -19,10 +20,12 @@ function isInitialized() {
19
20
  var SYNC_FILES = ["settings.json", "CLAUDE.md"];
20
21
  var SYNC_DIRS = ["commands", "agents"];
21
22
  var PLUGIN_FILES = ["installed_plugins.json", "known_marketplaces.json"];
23
+ var MCP_SYNC_FILE = "mcp-servers.json";
22
24
 
23
25
  // src/commands/export.ts
24
26
  import fs4 from "fs-extra";
25
27
  import path4 from "path";
28
+ import readline from "readline/promises";
26
29
 
27
30
  // src/core/logger.ts
28
31
  import pc from "picocolors";
@@ -64,8 +67,11 @@ function readJsonSafe(filePath) {
64
67
  return fs2.readJsonSync(filePath);
65
68
  } catch (err) {
66
69
  const code = err.code;
67
- if (code !== "ENOENT") {
68
- logWarn(`Failed to parse ${filePath}: ${err.message}`);
70
+ if (code === "ENOENT") return null;
71
+ if (err instanceof SyntaxError) {
72
+ logWarn(`Failed to parse JSON ${filePath}: ${err.message}`);
73
+ } else {
74
+ logWarn(`Failed to read ${filePath}: ${err.message}`);
69
75
  }
70
76
  return null;
71
77
  }
@@ -76,6 +82,11 @@ function writeJsonSafe(filePath, data) {
76
82
  }
77
83
 
78
84
  // src/core/sanitize.ts
85
+ function mcpServersHaveEnv(servers) {
86
+ return Object.values(servers).some(
87
+ (config) => typeof config === "object" && config !== null && "env" in config
88
+ );
89
+ }
79
90
  function sanitizePlugins(data) {
80
91
  const result = structuredClone(data);
81
92
  for (const entries of Object.values(result.plugins ?? {})) {
@@ -134,14 +145,34 @@ function importSkills(srcDir, destDir) {
134
145
  if (entry.isDirectory()) {
135
146
  const src = path3.join(srcDir, entry.name);
136
147
  const dest = path3.join(destDir, entry.name);
137
- fs3.copySync(src, dest, { overwrite: true });
148
+ copyDirClean(src, dest);
138
149
  logInfo(`Imported skill: ${entry.name}`);
139
150
  }
140
151
  }
141
152
  }
142
153
 
143
154
  // src/commands/export.ts
144
- function cmdExport() {
155
+ async function confirmMcpExport() {
156
+ const rl = readline.createInterface({
157
+ input: process.stdin,
158
+ output: process.stdout
159
+ });
160
+ try {
161
+ const answer = await rl.question(
162
+ '[WARN] MCP server configs contain "env" fields that may include secrets (API keys, tokens).\n Export anyway? (y/N): '
163
+ );
164
+ return answer.trim().toLowerCase() === "y";
165
+ } finally {
166
+ rl.close();
167
+ }
168
+ }
169
+ async function cmdExport() {
170
+ if (!fs4.existsSync(CLAUDE_HOME)) {
171
+ throw new Error(
172
+ `Claude config directory not found: ${CLAUDE_HOME}
173
+ Make sure Claude Code has been run at least once.`
174
+ );
175
+ }
145
176
  const configDir = getConfigDir();
146
177
  const pluginsDir = path4.join(configDir, "plugins");
147
178
  fs4.ensureDirSync(pluginsDir);
@@ -187,6 +218,21 @@ function cmdExport() {
187
218
  );
188
219
  logInfo("Exported: plugins/known_marketplaces.json (sanitized)");
189
220
  }
221
+ const claudeJson = readJsonSafe(CLAUDE_JSON);
222
+ const mcpServers = claudeJson?.mcpServers;
223
+ if (typeof mcpServers === "object" && mcpServers !== null) {
224
+ const servers = mcpServers;
225
+ let shouldExport = true;
226
+ if (mcpServersHaveEnv(servers)) {
227
+ shouldExport = await confirmMcpExport();
228
+ }
229
+ if (shouldExport) {
230
+ writeJsonSafe(path4.join(configDir, MCP_SYNC_FILE), servers);
231
+ logInfo(`Exported: ${MCP_SYNC_FILE} (from ~/.claude.json)`);
232
+ } else {
233
+ logWarn(`Skipped: ${MCP_SYNC_FILE} (user declined due to env secrets)`);
234
+ }
235
+ }
190
236
  logOk("Export complete");
191
237
  }
192
238
 
@@ -218,22 +264,24 @@ function backupExisting() {
218
264
  fs5.copySync(skillsDir, path5.join(backupDir, "skills"));
219
265
  }
220
266
  const pluginsDir = path5.join(CLAUDE_HOME, "plugins");
267
+ fs5.ensureDirSync(path5.join(backupDir, "plugins"));
221
268
  for (const file of PLUGIN_FILES) {
222
269
  const src = path5.join(pluginsDir, file);
223
270
  if (fs5.existsSync(src)) {
224
- fs5.ensureDirSync(path5.join(backupDir, "plugins"));
225
271
  fs5.copySync(src, path5.join(backupDir, "plugins", file));
226
272
  }
227
273
  }
274
+ if (fs5.existsSync(CLAUDE_JSON)) {
275
+ fs5.copySync(CLAUDE_JSON, path5.join(backupDir, ".claude.json"));
276
+ }
228
277
  logOk(`Backup created: ${backupDir}`);
229
- return backupDir;
230
278
  }
231
279
  function listBackups() {
232
280
  if (!fs5.existsSync(BACKUP_BASE)) return [];
233
281
  return fs5.readdirSync(BACKUP_BASE, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
234
282
  }
235
283
  function restoreFromBackup(backupName) {
236
- if (!backupName || backupName.includes("/") || backupName.includes("\\") || backupName.includes("..")) {
284
+ if (!backupName || backupName.includes("/") || backupName.includes("\\")) {
237
285
  throw new Error(`Invalid backup name: ${backupName}`);
238
286
  }
239
287
  const backupDir = path5.join(BACKUP_BASE, backupName);
@@ -275,11 +323,16 @@ function restoreFromBackup(backupName) {
275
323
  }
276
324
  }
277
325
  }
326
+ const claudeJsonSrc = path5.join(backupDir, ".claude.json");
327
+ if (fs5.existsSync(claudeJsonSrc)) {
328
+ fs5.copySync(claudeJsonSrc, CLAUDE_JSON);
329
+ logInfo("Restored: .claude.json");
330
+ }
278
331
  logOk(`Restored from backup: ${backupName}`);
279
332
  }
280
333
 
281
334
  // src/core/plugins.ts
282
- import { execFileSync, spawnSync } from "child_process";
335
+ import { spawnSync } from "child_process";
283
336
  function isNonNullObject(value) {
284
337
  return typeof value === "object" && value !== null && !Array.isArray(value);
285
338
  }
@@ -299,17 +352,12 @@ function execClaude(args) {
299
352
  return result.status === 0;
300
353
  }
301
354
  function isClaudeAvailable() {
302
- try {
303
- execFileSync("claude", ["--version"], { stdio: "pipe", timeout: 5e3, shell: true });
304
- return true;
305
- } catch {
306
- return false;
307
- }
355
+ const result = spawnSync("claude", ["--version"], { stdio: "pipe", timeout: 5e3, shell: true });
356
+ return result.status === 0;
308
357
  }
309
358
  function reinstallPlugins(marketplacesFile, pluginsFile, settingsFile, existingState) {
310
359
  if (!isClaudeAvailable()) {
311
- logError("claude CLI not found. Cannot reinstall plugins.");
312
- return;
360
+ throw new Error("claude CLI not found. Cannot reinstall plugins.");
313
361
  }
314
362
  logInfo("Phase 1: Adding plugin marketplaces...");
315
363
  const marketplaces = readJsonSafe(marketplacesFile);
@@ -417,24 +465,19 @@ function validatePluginsJson(filePath) {
417
465
  }
418
466
  return ok(data);
419
467
  }
420
- function validateMarketplacesJson(filePath) {
421
- return validateJsonFile(filePath);
422
- }
423
468
 
424
469
  // src/commands/import.ts
425
- function cmdImport(options = {}) {
426
- const configDir = getConfigDir();
427
- if (!fs6.existsSync(configDir)) {
428
- throw new Error(
429
- `Config directory not found: ${configDir}
430
- Run "vibe-sync export" first or "vibe-sync pull"`
431
- );
432
- }
433
- const dryRun = !!options.dryRun;
434
- if (dryRun) logInfo("Dry run - no changes will be made\n");
435
- if (!dryRun) backupExisting();
470
+ function validateSyncFile(file, src) {
471
+ if (file === "settings.json") return validateSettingsJson(src);
472
+ return validateJsonFile(src);
473
+ }
474
+ function validatePluginFile(file, src) {
475
+ if (file === "known_marketplaces.json") return validateJsonFile(src);
476
+ return validatePluginsJson(src);
477
+ }
478
+ function captureExistingPluginState() {
436
479
  const pluginsDest = path6.join(CLAUDE_HOME, "plugins");
437
- const existingState = {
480
+ return {
438
481
  marketplaces: readJsonSafe(
439
482
  path6.join(pluginsDest, "known_marketplaces.json")
440
483
  ),
@@ -445,39 +488,48 @@ Run "vibe-sync export" first or "vibe-sync pull"`
445
488
  path6.join(CLAUDE_HOME, "settings.json")
446
489
  )
447
490
  };
491
+ }
492
+ function importFile(file, src, stripPluginSettings, dryRun) {
493
+ const dest = path6.join(CLAUDE_HOME, file);
494
+ const exists = fs6.existsSync(dest);
495
+ if (dryRun) {
496
+ const suffix2 = stripPluginSettings ? " (without enabledPlugins)" : "";
497
+ logInfo(`Would import: ${file}${exists ? " (overwrite)" : " (new)"}${suffix2}`);
498
+ return;
499
+ }
500
+ fs6.ensureDirSync(path6.dirname(dest));
501
+ if (stripPluginSettings) {
502
+ const data = readJsonSafe(src);
503
+ if (!data) {
504
+ logWarn(`Skipping ${file}: invalid JSON, cannot strip enabledPlugins`);
505
+ return;
506
+ }
507
+ delete data.enabledPlugins;
508
+ fs6.writeJsonSync(dest, data, { spaces: 2 });
509
+ } else {
510
+ fs6.copySync(src, dest);
511
+ }
512
+ const suffix = stripPluginSettings ? " (stripped enabledPlugins)" : "";
513
+ logInfo(`Imported: ${file}${suffix}`);
514
+ }
515
+ function importSyncFiles(configDir, options) {
516
+ const dryRun = !!options.dryRun;
448
517
  for (const file of SYNC_FILES) {
449
518
  const src = path6.join(configDir, file);
450
519
  if (!fs6.existsSync(src)) continue;
451
520
  if (file.endsWith(".json")) {
452
- const result = file === "settings.json" ? validateSettingsJson(src) : validateJsonFile(src);
521
+ const result = validateSyncFile(file, src);
453
522
  if (!result.valid) {
454
- if (dryRun) logWarn(` INVALID: ${result.errors.join(", ")}`);
455
- else logWarn(`Skipping ${file}: ${result.errors.join(", ")}`);
523
+ logWarn(dryRun ? ` INVALID: ${result.errors.join(", ")}` : `Skipping ${file}: ${result.errors.join(", ")}`);
456
524
  continue;
457
525
  }
458
526
  if (dryRun) logOk(` Validated: ${file}`);
459
527
  }
460
- const dest = path6.join(CLAUDE_HOME, file);
461
- const exists = fs6.existsSync(dest);
462
528
  const stripPluginSettings = file === "settings.json" && !options.reinstallPlugins;
463
- if (dryRun) {
464
- logInfo(`Would import: ${file}${exists ? " (overwrite)" : " (new)"}${stripPluginSettings ? " (without enabledPlugins)" : ""}`);
465
- } else {
466
- fs6.ensureDirSync(path6.dirname(dest));
467
- if (stripPluginSettings) {
468
- const data = readJsonSafe(src);
469
- if (data) {
470
- delete data.enabledPlugins;
471
- fs6.writeJsonSync(dest, data, { spaces: 2 });
472
- } else {
473
- fs6.copySync(src, dest);
474
- }
475
- } else {
476
- fs6.copySync(src, dest);
477
- }
478
- logInfo(`Imported: ${file}${stripPluginSettings ? " (stripped enabledPlugins)" : ""}`);
479
- }
529
+ importFile(file, src, stripPluginSettings, dryRun);
480
530
  }
531
+ }
532
+ function importSyncDirs(configDir, dryRun) {
481
533
  for (const dir of SYNC_DIRS) {
482
534
  const src = path6.join(configDir, dir);
483
535
  if (!fs6.existsSync(src)) continue;
@@ -489,6 +541,8 @@ Run "vibe-sync export" first or "vibe-sync pull"`
489
541
  logInfo(`Imported: ${dir}/`);
490
542
  }
491
543
  }
544
+ }
545
+ function importSyncSkills(configDir, dryRun) {
492
546
  const skillsSrc = path6.join(configDir, "skills");
493
547
  if (dryRun && fs6.existsSync(skillsSrc)) {
494
548
  const entries = fs6.readdirSync(skillsSrc, { withFileTypes: true });
@@ -498,50 +552,113 @@ Run "vibe-sync export" first or "vibe-sync pull"`
498
552
  } else if (!dryRun) {
499
553
  importSkills(skillsSrc, path6.join(CLAUDE_HOME, "skills"));
500
554
  }
555
+ }
556
+ function readLocalMcpServers() {
557
+ const json = readJsonSafe(CLAUDE_JSON) ?? {};
558
+ const mcpServers = json.mcpServers;
559
+ const servers = typeof mcpServers === "object" && mcpServers !== null ? mcpServers : {};
560
+ return { json, servers };
561
+ }
562
+ function importMcpServers(configDir, dryRun) {
563
+ const src = path6.join(configDir, MCP_SYNC_FILE);
564
+ if (!fs6.existsSync(src)) return;
565
+ const result = validateJsonFile(src);
566
+ if (!result.valid) {
567
+ logWarn(dryRun ? ` INVALID: ${result.errors.join(", ")}` : `Skipping ${MCP_SYNC_FILE}: ${result.errors.join(", ")}`);
568
+ return;
569
+ }
570
+ if (dryRun) logOk(` Validated: ${MCP_SYNC_FILE}`);
571
+ const syncedServers = readJsonSafe(src);
572
+ if (!syncedServers) return;
573
+ const { json: existing, servers: currentServers } = readLocalMcpServers();
574
+ const syncedNames = Object.keys(syncedServers);
575
+ const newNames = syncedNames.filter((n) => !(n in currentServers));
576
+ if (dryRun) {
577
+ const skipNames = syncedNames.filter((n) => n in currentServers);
578
+ if (newNames.length > 0) logInfo(`Would import MCP servers: ${newNames.join(", ")}`);
579
+ if (skipNames.length > 0) logWarn(`Would skip (already exist): ${skipNames.join(", ")}`);
580
+ if (newNames.length === 0) logInfo("No new MCP servers to import");
581
+ return;
582
+ }
583
+ if (newNames.length === 0) {
584
+ logInfo("No new MCP servers to import (all already exist locally)");
585
+ return;
586
+ }
587
+ for (const name of syncedNames) {
588
+ if (name in currentServers) {
589
+ logWarn(`MCP server already exists locally, skipping: ${name}`);
590
+ }
591
+ }
592
+ const newServers = Object.fromEntries(
593
+ newNames.map((name) => [name, syncedServers[name]])
594
+ );
595
+ existing.mcpServers = { ...currentServers, ...newServers };
596
+ fs6.writeJsonSync(CLAUDE_JSON, existing, { spaces: 2 });
597
+ logInfo(`Imported: ${MCP_SYNC_FILE} \u2192 ~/.claude.json (added ${newNames.length} new server(s))`);
598
+ }
599
+ function syncPlugins(configDir, existingState, dryRun) {
501
600
  const pluginsSrc = path6.join(configDir, "plugins");
502
- if (options.reinstallPlugins) {
503
- const hasManifest = PLUGIN_FILES.some(
504
- (f) => fs6.existsSync(path6.join(pluginsSrc, f))
505
- );
506
- if (!hasManifest) {
507
- logInfo("No plugin manifest files found in sync directory");
508
- } else {
509
- const pluginValidators = {
510
- "installed_plugins.json": validatePluginsJson,
511
- "known_marketplaces.json": validateMarketplacesJson
512
- };
513
- let validationPassed = true;
514
- for (const file of PLUGIN_FILES) {
515
- const src = path6.join(pluginsSrc, file);
516
- if (!fs6.existsSync(src)) continue;
517
- const result = pluginValidators[file](src);
518
- if (!result.valid) {
519
- logWarn(`${dryRun ? " INVALID" : `Invalid plugins/${file}`}: ${result.errors.join(", ")}`);
520
- validationPassed = false;
521
- } else if (dryRun) {
522
- logOk(` Validated: plugins/${file}`);
523
- }
524
- }
525
- if (!validationPassed) {
526
- logWarn("Skipping plugin reinstallation due to validation errors");
527
- } else if (dryRun) {
528
- logInfo("Would reinstall plugins via claude CLI");
529
- } else {
530
- reinstallPlugins(
531
- path6.join(pluginsSrc, "known_marketplaces.json"),
532
- path6.join(pluginsSrc, "installed_plugins.json"),
533
- path6.join(CLAUDE_HOME, "settings.json"),
534
- existingState
535
- );
536
- }
601
+ const hasManifest = PLUGIN_FILES.some(
602
+ (f) => fs6.existsSync(path6.join(pluginsSrc, f))
603
+ );
604
+ if (!hasManifest) {
605
+ logInfo("No plugin manifest files found in sync directory");
606
+ return;
607
+ }
608
+ let validationPassed = true;
609
+ for (const file of PLUGIN_FILES) {
610
+ const src = path6.join(pluginsSrc, file);
611
+ if (!fs6.existsSync(src)) continue;
612
+ const result = validatePluginFile(file, src);
613
+ if (!result.valid) {
614
+ logWarn(dryRun ? ` INVALID: ${result.errors.join(", ")}` : `Invalid plugins/${file}: ${result.errors.join(", ")}`);
615
+ validationPassed = false;
616
+ } else if (dryRun) {
617
+ logOk(` Validated: plugins/${file}`);
537
618
  }
619
+ }
620
+ if (!validationPassed) {
621
+ logWarn("Skipping plugin reinstallation due to validation errors");
622
+ return;
623
+ }
624
+ if (dryRun) {
625
+ logInfo("Would reinstall plugins via claude CLI");
626
+ return;
627
+ }
628
+ reinstallPlugins(
629
+ path6.join(pluginsSrc, "known_marketplaces.json"),
630
+ path6.join(pluginsSrc, "installed_plugins.json"),
631
+ path6.join(CLAUDE_HOME, "settings.json"),
632
+ existingState
633
+ );
634
+ }
635
+ function cmdImport(options = {}) {
636
+ const configDir = getConfigDir();
637
+ if (!fs6.existsSync(configDir)) {
638
+ throw new Error(
639
+ `Config directory not found: ${configDir}
640
+ Run "vibe-sync export" first or "vibe-sync pull"`
641
+ );
642
+ }
643
+ const dryRun = !!options.dryRun;
644
+ if (dryRun) logInfo("Dry run - no changes will be made\n");
645
+ if (!dryRun) backupExisting();
646
+ const existingState = captureExistingPluginState();
647
+ importSyncFiles(configDir, options);
648
+ importSyncDirs(configDir, dryRun);
649
+ importSyncSkills(configDir, dryRun);
650
+ importMcpServers(configDir, dryRun);
651
+ if (options.reinstallPlugins) {
652
+ syncPlugins(configDir, existingState, dryRun);
538
653
  } else {
539
- logInfo("Skipping plugin sync (omit --no-plugins to enable)");
654
+ logInfo("Skipping plugin sync (remove --no-plugins flag to enable)");
540
655
  }
541
656
  if (!dryRun) logOk("Import complete");
542
657
  }
543
658
 
544
659
  // src/commands/status.ts
660
+ import fs8 from "fs-extra";
661
+ import os2 from "os";
545
662
  import path8 from "path";
546
663
 
547
664
  // src/core/diff.ts
@@ -552,9 +669,6 @@ import pc2 from "picocolors";
552
669
  function readNormalized(filePath) {
553
670
  return fs7.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
554
671
  }
555
- function filesEqual(a, b) {
556
- return readNormalized(a) === readNormalized(b);
557
- }
558
672
  function dirsEqual(a, b) {
559
673
  const aEntries = fs7.readdirSync(a).sort();
560
674
  const bEntries = fs7.readdirSync(b).sort();
@@ -563,36 +677,37 @@ function dirsEqual(a, b) {
563
677
  if (aEntries[i] !== bEntries[i]) return false;
564
678
  const aPath = path7.join(a, aEntries[i]);
565
679
  const bPath = path7.join(b, bEntries[i]);
566
- const aStat = fs7.statSync(aPath);
567
- const bStat = fs7.statSync(bPath);
568
- if (aStat.isDirectory() !== bStat.isDirectory()) return false;
569
- const equal = aStat.isDirectory() ? dirsEqual(aPath, bPath) : filesEqual(aPath, bPath);
570
- if (!equal) return false;
680
+ try {
681
+ const aStat = fs7.statSync(aPath);
682
+ const bStat = fs7.statSync(bPath);
683
+ if (aStat.isDirectory() !== bStat.isDirectory()) return false;
684
+ const equal = aStat.isDirectory() ? dirsEqual(aPath, bPath) : readNormalized(aPath) === readNormalized(bPath);
685
+ if (!equal) return false;
686
+ } catch {
687
+ return false;
688
+ }
571
689
  }
572
690
  return true;
573
691
  }
574
- function colorDiff(patch) {
575
- return patch.split("\n").map((line) => {
576
- if (line.startsWith("+")) return pc2.green(line);
577
- if (line.startsWith("-")) return pc2.red(line);
578
- if (line.startsWith("@@")) return pc2.cyan(line);
579
- return line;
580
- }).join("\n");
692
+ function colorDiffLine(line) {
693
+ if (line.startsWith("@@")) return pc2.cyan(line);
694
+ if (line.startsWith("+")) return pc2.green(line);
695
+ if (line.startsWith("-")) return pc2.red(line);
696
+ return line;
581
697
  }
582
698
  function showDiff(localPath, repoPath, label) {
583
- if (!fs7.existsSync(localPath) && !fs7.existsSync(repoPath)) {
584
- return false;
585
- }
586
- if (!fs7.existsSync(localPath)) {
699
+ const localExists = fs7.existsSync(localPath);
700
+ const repoExists = fs7.existsSync(repoPath);
701
+ if (!localExists && !repoExists) return false;
702
+ if (!localExists) {
587
703
  console.log(pc2.yellow(` ${label}: exists in repo but not locally`));
588
704
  return true;
589
705
  }
590
- if (!fs7.existsSync(repoPath)) {
706
+ if (!repoExists) {
591
707
  console.log(pc2.yellow(` ${label}: exists locally but not in repo`));
592
708
  return true;
593
709
  }
594
- const isDir = fs7.statSync(localPath).isDirectory();
595
- if (isDir) {
710
+ if (fs7.statSync(localPath).isDirectory()) {
596
711
  if (dirsEqual(localPath, repoPath)) return false;
597
712
  console.log(pc2.yellow(` ${label}: differs`));
598
713
  return true;
@@ -607,7 +722,7 @@ function showDiff(localPath, repoPath, label) {
607
722
  localContent,
608
723
  repoContent
609
724
  );
610
- console.log(colorDiff(patch));
725
+ console.log(patch.split("\n").map(colorDiffLine).join("\n"));
611
726
  return true;
612
727
  }
613
728
 
@@ -634,13 +749,30 @@ function cmdStatus() {
634
749
  if (showDiff(localSkills, repoSkills, "skills/")) {
635
750
  hasDiff = true;
636
751
  }
637
- for (const file of ["installed_plugins.json", "known_marketplaces.json"]) {
752
+ for (const file of PLUGIN_FILES) {
638
753
  const local = path8.join(CLAUDE_HOME, "plugins", file);
639
754
  const repo = path8.join(configDir, "plugins", file);
640
755
  if (showDiff(local, repo, `plugins/${file}`)) {
641
756
  hasDiff = true;
642
757
  }
643
758
  }
759
+ const repoMcp = path8.join(configDir, MCP_SYNC_FILE);
760
+ const claudeJson = readJsonSafe(CLAUDE_JSON);
761
+ const localMcp = typeof claudeJson?.mcpServers === "object" && claudeJson.mcpServers !== null ? claudeJson.mcpServers : void 0;
762
+ if (localMcp || fs8.existsSync(repoMcp)) {
763
+ const tmpFile = path8.join(os2.tmpdir(), `vibe-sync-mcp-${Date.now()}.json`);
764
+ try {
765
+ if (localMcp) {
766
+ fs8.writeJsonSync(tmpFile, localMcp, { spaces: 2 });
767
+ }
768
+ const localPath = localMcp ? tmpFile : "";
769
+ if (showDiff(localPath, repoMcp, MCP_SYNC_FILE)) {
770
+ hasDiff = true;
771
+ }
772
+ } finally {
773
+ fs8.removeSync(tmpFile);
774
+ }
775
+ }
644
776
  if (!hasDiff) {
645
777
  logOk("No differences found");
646
778
  }
@@ -666,43 +798,85 @@ async function hasRemote(git) {
666
798
  return false;
667
799
  }
668
800
  }
801
+ function setUpstreamArgs(remoteBranch, localBranch) {
802
+ return ["--set-upstream-to=origin/" + remoteBranch, localBranch];
803
+ }
669
804
  async function commitAndPush(git) {
670
805
  await ensureGitRepo(git);
671
806
  await git.add("-A");
672
807
  const status = await git.status();
673
- if (status.isClean()) {
674
- logInfo("No changes to commit");
675
- } else {
808
+ const hasChanges = !status.isClean();
809
+ if (hasChanges) {
676
810
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", "_");
677
811
  const msg = `sync: update claude configs ${timestamp}`;
678
812
  await git.commit(msg);
679
813
  logOk(`Committed: ${msg}`);
680
- }
681
- if (await hasRemote(git)) {
682
- const branch = (await git.branchLocal()).current || "main";
683
- await git.push(["-u", "origin", branch]);
684
- logOk("Pushed to remote");
685
814
  } else {
815
+ logInfo("No changes to commit");
816
+ }
817
+ if (!await hasRemote(git)) {
686
818
  logWarn("No remote configured. Run: git remote add origin <url>");
819
+ return hasChanges;
820
+ }
821
+ const branch = (await git.branchLocal()).current || "main";
822
+ try {
823
+ await git.push(["-u", "origin", branch]);
824
+ } catch {
825
+ logInfo("Push rejected, pulling remote changes first");
826
+ await pullFromRemote(git);
827
+ await git.push(["-u", "origin", branch]);
828
+ }
829
+ logOk("Pushed to remote");
830
+ return hasChanges;
831
+ }
832
+ async function detectRemoteBranch(git) {
833
+ try {
834
+ const remote = await git.remote(["show", "origin"]);
835
+ if (remote) {
836
+ const match = remote.match(/HEAD branch:\s*(\S+)/);
837
+ if (match) return match[1];
838
+ }
839
+ } catch {
687
840
  }
688
- return !status.isClean();
841
+ try {
842
+ const refs = await git.listRemote(["--heads", "origin"]);
843
+ if (refs.includes("refs/heads/main")) return "main";
844
+ if (refs.includes("refs/heads/master")) return "master";
845
+ } catch {
846
+ }
847
+ return "main";
689
848
  }
690
849
  async function pullFromRemote(git) {
691
850
  if (!await hasRemote(git)) {
692
851
  throw new Error("No remote configured. Run: git remote add origin <url>");
693
852
  }
694
- const branch = (await git.branchLocal()).current || "main";
853
+ const localBranch = (await git.branchLocal()).current;
854
+ const remoteBranch = await detectRemoteBranch(git);
855
+ const branch = localBranch || remoteBranch;
695
856
  try {
696
857
  await git.pull();
697
858
  } catch {
698
859
  try {
699
- await git.pull("origin", branch);
700
- await git.branch(["--set-upstream-to=origin/" + branch, branch]);
860
+ await git.pull("origin", remoteBranch);
861
+ await git.branch(setUpstreamArgs(remoteBranch, branch));
862
+ } catch {
863
+ logWarn("Merge conflict detected. Stashing local changes before resetting to remote version.");
864
+ await git.fetch("origin", remoteBranch);
865
+ try {
866
+ await git.stash(["push", "-m", `vibe-sync: pre-reset backup ${(/* @__PURE__ */ new Date()).toISOString()}`]);
867
+ logInfo('Local changes stashed. Use "git stash pop" in ~/.vibe-sync to recover if needed.');
868
+ } catch {
869
+ }
870
+ await git.reset(["--hard", `origin/${remoteBranch}`]);
871
+ await git.clean("f", ["-d"]);
872
+ await git.branch(setUpstreamArgs(remoteBranch, branch));
873
+ }
874
+ }
875
+ if (localBranch && localBranch !== remoteBranch) {
876
+ try {
877
+ await git.branch(["-m", localBranch, remoteBranch]);
878
+ await git.branch(setUpstreamArgs(remoteBranch, remoteBranch));
701
879
  } catch {
702
- logWarn("Merge conflict detected, resetting to remote version");
703
- await git.fetch("origin", branch);
704
- await git.reset(["--hard", `origin/${branch}`]);
705
- await git.branch(["--set-upstream-to=origin/" + branch, branch]);
706
880
  }
707
881
  }
708
882
  logOk("Pulled from remote");
@@ -710,7 +884,7 @@ async function pullFromRemote(git) {
710
884
 
711
885
  // src/commands/push.ts
712
886
  async function cmdPush() {
713
- cmdExport();
887
+ await cmdExport();
714
888
  const git = createGit(SYNC_DIR);
715
889
  await commitAndPush(git);
716
890
  }
@@ -719,7 +893,24 @@ async function cmdPush() {
719
893
  async function cmdPull(options = {}) {
720
894
  const git = createGit(SYNC_DIR);
721
895
  if (options.dryRun) {
722
- logInfo("Dry run: skipping git pull");
896
+ if (await hasRemote(git)) {
897
+ await git.fetch();
898
+ const localHead = await git.revparse(["HEAD"]);
899
+ const remoteHead = await git.revparse(["FETCH_HEAD"]).catch(() => null);
900
+ if (remoteHead && localHead !== remoteHead) {
901
+ const summary = await git.diffSummary(["HEAD", "FETCH_HEAD"]);
902
+ logInfo(`Remote has ${summary.changed} changed file(s) ahead of local:`);
903
+ for (const file of summary.files) {
904
+ const ins = "insertions" in file ? file.insertions : 0;
905
+ const del = "deletions" in file ? file.deletions : 0;
906
+ logInfo(` ${file.file} (+${ins} -${del})`);
907
+ }
908
+ } else {
909
+ logInfo("Remote is up to date with local");
910
+ }
911
+ } else {
912
+ logWarn("No remote configured, cannot fetch for preview");
913
+ }
723
914
  } else {
724
915
  await pullFromRemote(git);
725
916
  }
@@ -727,87 +918,77 @@ async function cmdPull(options = {}) {
727
918
  }
728
919
 
729
920
  // src/commands/init.ts
730
- import fs8 from "fs-extra";
921
+ import fs9 from "fs-extra";
731
922
  import path9 from "path";
732
- import readline from "readline/promises";
923
+ import readline2 from "readline/promises";
924
+ var GIT_URL_PATTERN = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
733
925
  function createReadline() {
734
- return readline.createInterface({
926
+ return readline2.createInterface({
735
927
  input: process.stdin,
736
928
  output: process.stdout
737
929
  });
738
930
  }
739
- async function cmdInit() {
740
- if (isInitialized()) {
741
- const git = createGit(SYNC_DIR);
742
- const remotes = await git.getRemotes(true);
743
- const origin = remotes.find((r) => r.name === "origin");
931
+ async function promptUrl(rl, prompt) {
932
+ const url = await rl.question(prompt);
933
+ const trimmed = url.trim();
934
+ if (!trimmed) return null;
935
+ if (!GIT_URL_PATTERN.test(trimmed)) {
936
+ logError("Invalid Git URL format. Expected https://, git@, ssh://, or git:// URL");
937
+ return null;
938
+ }
939
+ return trimmed;
940
+ }
941
+ async function updateRemote() {
942
+ const git = createGit(SYNC_DIR);
943
+ const remotes = await git.getRemotes(true);
944
+ const origin = remotes.find((r) => r.name === "origin");
945
+ if (origin) {
946
+ logInfo(`Current remote: ${origin.refs.fetch}`);
947
+ } else {
948
+ logWarn("No remote configured");
949
+ }
950
+ const rl = createReadline();
951
+ try {
952
+ const url = await promptUrl(rl, "? New Git remote URL (leave empty to keep current): ");
953
+ if (!url) {
954
+ logInfo("Remote unchanged");
955
+ return;
956
+ }
744
957
  if (origin) {
745
- logInfo(`Current remote: ${origin.refs.fetch}`);
958
+ await git.remote(["set-url", "origin", url]);
746
959
  } else {
747
- logWarn("No remote configured");
748
- }
749
- const rl2 = createReadline();
750
- try {
751
- const url = await rl2.question("? New Git remote URL (leave empty to keep current): ");
752
- const trimmedUrl = url.trim();
753
- if (!trimmedUrl) {
754
- logInfo("Remote unchanged");
755
- return;
756
- }
757
- const gitUrlPattern = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
758
- if (!gitUrlPattern.test(trimmedUrl)) {
759
- logError("Invalid Git URL format. Expected https://, git@, ssh://, or git:// URL");
760
- return;
761
- }
762
- if (origin) {
763
- await git.remote(["set-url", "origin", trimmedUrl]);
764
- } else {
765
- await git.addRemote("origin", trimmedUrl);
766
- }
767
- logOk(`Remote updated: ${trimmedUrl}`);
768
- } finally {
769
- rl2.close();
960
+ await git.addRemote("origin", url);
770
961
  }
771
- return;
962
+ logOk(`Remote updated: ${url}`);
963
+ } finally {
964
+ rl.close();
772
965
  }
966
+ }
967
+ async function freshInit() {
773
968
  console.log("");
774
969
  console.log("Welcome to vibe-sync! Let's set up config synchronization.");
775
970
  console.log("");
776
971
  const rl = createReadline();
777
972
  try {
778
- const url = await rl.question("? Git remote URL: ");
779
- const trimmedUrl = url.trim();
780
- if (!trimmedUrl) {
973
+ const url = await promptUrl(rl, "? Git remote URL: ");
974
+ if (!url) {
781
975
  logError("URL cannot be empty");
782
976
  return;
783
977
  }
784
- const gitUrlPattern = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
785
- if (!gitUrlPattern.test(trimmedUrl)) {
786
- logError("Invalid Git URL format. Expected https://, git@, ssh://, or git:// URL");
787
- return;
788
- }
789
- fs8.ensureDirSync(SYNC_DIR);
978
+ fs9.ensureDirSync(SYNC_DIR);
790
979
  const git = createGit(SYNC_DIR);
791
980
  await git.init();
792
981
  logOk("Initialized git repository");
793
- await git.addRemote("origin", trimmedUrl);
794
- logOk(`Remote added: ${trimmedUrl}`);
982
+ await git.addRemote("origin", url);
983
+ logOk(`Remote added: ${url}`);
795
984
  try {
796
- await git.pull("origin", "main");
797
- await git.branch(["--set-upstream-to=origin/main", "main"]);
798
- logOk("Pulled existing data from remote");
985
+ await pullFromRemote(git);
799
986
  } catch {
800
- try {
801
- await git.pull("origin", "master");
802
- await git.branch(["--set-upstream-to=origin/master", "master"]);
803
- logOk("Pulled existing data from remote");
804
- } catch {
805
- logInfo("No existing data on remote (new repository)");
806
- }
987
+ logInfo("No existing data on remote (new repository)");
807
988
  }
808
989
  const gitignorePath = path9.join(SYNC_DIR, ".gitignore");
809
- if (!fs8.existsSync(gitignorePath)) {
810
- fs8.writeFileSync(gitignorePath, ".DS_Store\nThumbs.db\n");
990
+ if (!fs9.existsSync(gitignorePath)) {
991
+ fs9.writeFileSync(gitignorePath, ".DS_Store\nThumbs.db\n");
811
992
  }
812
993
  console.log("");
813
994
  logOk("Setup complete!");
@@ -820,13 +1001,19 @@ async function cmdInit() {
820
1001
  rl.close();
821
1002
  }
822
1003
  }
1004
+ async function cmdInit() {
1005
+ if (isInitialized()) {
1006
+ await updateRemote();
1007
+ return;
1008
+ }
1009
+ await freshInit();
1010
+ }
823
1011
 
824
1012
  // src/commands/restore.ts
825
1013
  function cmdRestore(timestamp) {
826
1014
  const backups = listBackups();
827
1015
  if (backups.length === 0) {
828
- logError('No backups found. Run "vibe-sync import" first to create a backup.');
829
- return;
1016
+ throw new Error('No backups found. Run "vibe-sync import" first to create a backup.');
830
1017
  }
831
1018
  if (!timestamp) {
832
1019
  logInfo(`Available backups (${backups.length}):`);
@@ -840,15 +1027,14 @@ function cmdRestore(timestamp) {
840
1027
  return;
841
1028
  }
842
1029
  if (!backups.includes(timestamp)) {
843
- logError(`Backup not found: ${timestamp}`);
844
- return;
1030
+ throw new Error(`Backup not found: ${timestamp}`);
845
1031
  }
846
1032
  restoreFromBackup(timestamp);
847
1033
  }
848
1034
 
849
1035
  // src/index.ts
850
1036
  var program = new Command();
851
- program.name("vibe-sync").description("Sync AI coding tool configurations across machines via git").version("0.2.3").action(async () => {
1037
+ program.name("vibe-sync").description("Sync AI coding tool configurations across machines via git").version("0.3.0").action(async () => {
852
1038
  if (!isInitialized()) {
853
1039
  await cmdInit();
854
1040
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-config-sync",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Cross-platform CLI tool to sync AI coding tool configurations across machines via git",
5
5
  "type": "module",
6
6
  "bin": {