vibe-config-sync 0.0.1

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 ADDED
@@ -0,0 +1,127 @@
1
+ # vibe-sync
2
+
3
+ Sync AI coding tool configurations across machines via Git. Currently supports Claude Code (`~/.claude/`).
4
+
5
+ ## Problem
6
+
7
+ When using AI coding tools on multiple machines, configurations like skills, commands, agents, plugins, and user preferences need to be manually replicated on each machine.
8
+
9
+ ## What Gets Synced
10
+
11
+ | Content | Description |
12
+ |---------|-------------|
13
+ | `settings.json` | Plugin enable/disable flags |
14
+ | `CLAUDE.md` | User preferences and workflow guidelines |
15
+ | `skills/` | Skill definitions (SKILL.md + references + templates) |
16
+ | `commands/` | Custom slash commands |
17
+ | `agents/` | Agent definitions |
18
+ | `plugins/installed_plugins.json` | Plugin registry (machine paths stripped) |
19
+ | `plugins/known_marketplaces.json` | Plugin marketplace sources (machine paths stripped) |
20
+
21
+ **Not synced** (machine-specific / ephemeral / large): `plugins/cache/`, `projects/`, `telemetry/`, `session-env/`, `plans/`, `todos/`, `debug/`, `history.jsonl`, etc.
22
+
23
+ ## Prerequisites
24
+
25
+ - Node.js 18+
26
+ - `git`
27
+ - `claude` CLI (only needed for `--reinstall-plugins`)
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install -g vibe-sync
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ### Machine A (first time setup)
38
+
39
+ ```bash
40
+ vibe-sync init # Initialize and connect to a Git remote
41
+ vibe-sync push # Export configs and push to remote
42
+ ```
43
+
44
+ ### Machine B (import)
45
+
46
+ ```bash
47
+ vibe-sync init # Initialize and pull from remote
48
+ vibe-sync pull # Pull and import configs
49
+ vibe-sync pull --reinstall-plugins # Pull, import, and reinstall plugins
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ | Command | Description |
55
+ |---------|-------------|
56
+ | `vibe-sync init` | Initialize sync repository and connect to Git remote |
57
+ | `vibe-sync export` | Collect configs from `~/.claude/` into sync repo |
58
+ | `vibe-sync import` | Restore configs from sync repo to `~/.claude/` |
59
+ | `vibe-sync import --reinstall-plugins` | Restore + reinstall all plugins |
60
+ | `vibe-sync status` | Show diff between local and synced configs |
61
+ | `vibe-sync push` | export + git commit + git push |
62
+ | `vibe-sync pull` | git pull + import |
63
+ | `vibe-sync pull --reinstall-plugins` | git pull + import + reinstall all plugins |
64
+
65
+ ## Daily Workflow
66
+
67
+ ```bash
68
+ # After changing configs on Machine A
69
+ vibe-sync push
70
+
71
+ # On Machine B, pull latest
72
+ vibe-sync pull
73
+ ```
74
+
75
+ ## How It Works
76
+
77
+ - **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.
78
+
79
+ - **Import** restores files from `~/.vibe-sync/data/` to `~/.claude/`, and optionally reinstalls plugins via `claude plugin install`.
80
+
81
+ - **Backup** is created automatically at `~/.vibe-sync/backups/claude/<timestamp>/` before every import.
82
+
83
+ ## Sync Strategy
84
+
85
+ The overall strategy is **last-write-wins with no conflict detection**. There is no field-level merge — the most recent import overwrites the target.
86
+
87
+ ### Files (`settings.json`, `CLAUDE.md`)
88
+
89
+ Whole-file overwrite. The sync repo version replaces the local version entirely.
90
+
91
+ ### Directories (`skills/`, `commands/`, `agents/`)
92
+
93
+ Directory-level merge + file-level overwrite:
94
+
95
+ | Scenario | Behavior |
96
+ |----------|----------|
97
+ | Repo has `skills/foo/`, local does not | `foo/` is added |
98
+ | Both have `skills/foo/SKILL.md` | Repo version overwrites local |
99
+ | Local has `skills/bar/`, repo does not | `bar/` is **kept** (not deleted) |
100
+ | Repo's `skills/foo/` is missing a file that local has | Local file is **kept** |
101
+
102
+ In short: new content is added, existing content is overwritten, but nothing is deleted from the local side.
103
+
104
+ ### Symlinked Skills
105
+
106
+ - **Export**: symlinks are resolved via `realpathSync` and the actual file contents are copied — the symlink itself is not preserved
107
+ - **Import**: copied as regular directories, no symlink recreation needed. This ensures skills work across machines regardless of the original symlink target path
108
+
109
+ ### Plugin JSON
110
+
111
+ Whole-file overwrite, same as `settings.json`. No key-level merge between local and repo versions.
112
+
113
+ ### What This Means in Practice
114
+
115
+ - 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)
116
+ - Deleted files/skills on one machine will **not** be deleted on the other — only additions and modifications propagate
117
+ - Symlinked skills are resolved and copied as regular directories, so the synced version is a standalone snapshot independent of the original symlink target
118
+
119
+ ## Environment Variables
120
+
121
+ | Variable | Description | Default |
122
+ |----------|-------------|---------|
123
+ | `CLAUDE_HOME` | Override Claude config directory | `~/.claude` |
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/core/config.ts
7
+ import fs from "fs-extra";
8
+ import os from "os";
9
+ import path from "path";
10
+ var CLAUDE_HOME = process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude");
11
+ var SYNC_DIR = path.join(os.homedir(), ".vibe-sync");
12
+ var BACKUP_BASE = path.join(SYNC_DIR, "backups", "claude");
13
+ function getConfigDir() {
14
+ return path.join(SYNC_DIR, "data");
15
+ }
16
+ function isInitialized() {
17
+ return fs.existsSync(path.join(SYNC_DIR, ".git"));
18
+ }
19
+ var SYNC_FILES = ["settings.json", "CLAUDE.md"];
20
+ var SYNC_DIRS = ["commands", "agents"];
21
+
22
+ // src/commands/export.ts
23
+ import fs4 from "fs-extra";
24
+ import path4 from "path";
25
+
26
+ // src/core/logger.ts
27
+ import pc from "picocolors";
28
+ function logInfo(...args) {
29
+ console.log(pc.blue("[INFO]"), ...args);
30
+ }
31
+ function logOk(...args) {
32
+ console.log(pc.green("[OK]"), ...args);
33
+ }
34
+ function logWarn(...args) {
35
+ console.log(pc.yellow("[WARN]"), ...args);
36
+ }
37
+ function logError(...args) {
38
+ console.error(pc.red("[ERROR]"), ...args);
39
+ }
40
+
41
+ // src/core/fs-utils.ts
42
+ import fs2 from "fs-extra";
43
+ import path2 from "path";
44
+ function copyDirClean(src, dest) {
45
+ fs2.copySync(src, dest, { overwrite: true });
46
+ removeDsStore(dest);
47
+ }
48
+ function removeDsStore(dir) {
49
+ if (!fs2.existsSync(dir)) return;
50
+ const entries = fs2.readdirSync(dir, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ const fullPath = path2.join(dir, entry.name);
53
+ if (entry.name === ".DS_Store") {
54
+ fs2.removeSync(fullPath);
55
+ } else if (entry.isDirectory() && !entry.isSymbolicLink()) {
56
+ removeDsStore(fullPath);
57
+ }
58
+ }
59
+ }
60
+ function readJsonSafe(filePath) {
61
+ try {
62
+ return fs2.readJsonSync(filePath);
63
+ } catch (err) {
64
+ const code = err.code;
65
+ if (code !== "ENOENT") {
66
+ logWarn(`Failed to parse ${filePath}: ${err.message}`);
67
+ }
68
+ return null;
69
+ }
70
+ }
71
+ function writeJsonSafe(filePath, data) {
72
+ fs2.ensureDirSync(path2.dirname(filePath));
73
+ fs2.writeJsonSync(filePath, data, { spaces: 2 });
74
+ }
75
+
76
+ // src/core/sanitize.ts
77
+ function sanitizePlugins(data) {
78
+ const result = structuredClone(data);
79
+ for (const entries of Object.values(result.plugins ?? {})) {
80
+ for (const entry of entries) {
81
+ delete entry.installPath;
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ function sanitizeMarketplaces(data) {
87
+ const result = structuredClone(data);
88
+ for (const entry of Object.values(result)) {
89
+ delete entry.installLocation;
90
+ }
91
+ return result;
92
+ }
93
+
94
+ // src/core/skills.ts
95
+ import fs3 from "fs-extra";
96
+ import path3 from "path";
97
+ function exportSkills(srcDir, destDir) {
98
+ if (!fs3.existsSync(srcDir)) {
99
+ logWarn("Skills directory not found:", srcDir);
100
+ return;
101
+ }
102
+ fs3.ensureDirSync(destDir);
103
+ const entries = fs3.readdirSync(srcDir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const name = entry.name;
106
+ const fullPath = path3.join(srcDir, name);
107
+ if (entry.isSymbolicLink()) {
108
+ const realPath = fs3.realpathSync(fullPath);
109
+ copyDirClean(realPath, path3.join(destDir, name));
110
+ logInfo(`Exported skill (resolved symlink): ${name}`);
111
+ } else if (entry.isDirectory()) {
112
+ copyDirClean(fullPath, path3.join(destDir, name));
113
+ logInfo(`Exported skill: ${name}`);
114
+ }
115
+ }
116
+ logOk("Skills exported");
117
+ }
118
+ function importSkills(srcDir, destDir) {
119
+ if (!fs3.existsSync(srcDir)) return;
120
+ fs3.ensureDirSync(destDir);
121
+ const entries = fs3.readdirSync(srcDir, { withFileTypes: true });
122
+ for (const entry of entries) {
123
+ if (entry.isDirectory()) {
124
+ const src = path3.join(srcDir, entry.name);
125
+ const dest = path3.join(destDir, entry.name);
126
+ fs3.copySync(src, dest, { overwrite: true });
127
+ logInfo(`Imported skill: ${entry.name}`);
128
+ }
129
+ }
130
+ }
131
+
132
+ // src/commands/export.ts
133
+ function cmdExport() {
134
+ const configDir = getConfigDir();
135
+ const pluginsDir = path4.join(configDir, "plugins");
136
+ fs4.ensureDirSync(pluginsDir);
137
+ for (const file of SYNC_FILES) {
138
+ const src = path4.join(CLAUDE_HOME, file);
139
+ if (fs4.existsSync(src)) {
140
+ fs4.copySync(src, path4.join(configDir, file));
141
+ logInfo(`Exported: ${file}`);
142
+ } else {
143
+ logWarn(`Not found: ${file}`);
144
+ }
145
+ }
146
+ for (const dir of SYNC_DIRS) {
147
+ const src = path4.join(CLAUDE_HOME, dir);
148
+ if (fs4.existsSync(src)) {
149
+ copyDirClean(src, path4.join(configDir, dir));
150
+ logInfo(`Exported: ${dir}/`);
151
+ } else {
152
+ logWarn(`Not found: ${dir}/`);
153
+ }
154
+ }
155
+ exportSkills(
156
+ path4.join(CLAUDE_HOME, "skills"),
157
+ path4.join(configDir, "skills")
158
+ );
159
+ const installedPlugins = readJsonSafe(
160
+ path4.join(CLAUDE_HOME, "plugins", "installed_plugins.json")
161
+ );
162
+ if (installedPlugins) {
163
+ writeJsonSafe(
164
+ path4.join(pluginsDir, "installed_plugins.json"),
165
+ sanitizePlugins(installedPlugins)
166
+ );
167
+ logInfo("Exported: plugins/installed_plugins.json (sanitized)");
168
+ }
169
+ const marketplaces = readJsonSafe(
170
+ path4.join(CLAUDE_HOME, "plugins", "known_marketplaces.json")
171
+ );
172
+ if (marketplaces) {
173
+ writeJsonSafe(
174
+ path4.join(pluginsDir, "known_marketplaces.json"),
175
+ sanitizeMarketplaces(marketplaces)
176
+ );
177
+ logInfo("Exported: plugins/known_marketplaces.json (sanitized)");
178
+ }
179
+ logOk("Export complete");
180
+ }
181
+
182
+ // src/commands/import.ts
183
+ import fs6 from "fs-extra";
184
+ import path6 from "path";
185
+
186
+ // src/core/backup.ts
187
+ import fs5 from "fs-extra";
188
+ import path5 from "path";
189
+ function backupExisting() {
190
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
191
+ const backupDir = path5.join(BACKUP_BASE, timestamp);
192
+ fs5.ensureDirSync(backupDir);
193
+ for (const file of SYNC_FILES) {
194
+ const src = path5.join(CLAUDE_HOME, file);
195
+ if (fs5.existsSync(src)) {
196
+ fs5.copySync(src, path5.join(backupDir, file));
197
+ }
198
+ }
199
+ for (const dir of SYNC_DIRS) {
200
+ const src = path5.join(CLAUDE_HOME, dir);
201
+ if (fs5.existsSync(src)) {
202
+ fs5.copySync(src, path5.join(backupDir, dir));
203
+ }
204
+ }
205
+ const skillsDir = path5.join(CLAUDE_HOME, "skills");
206
+ if (fs5.existsSync(skillsDir)) {
207
+ fs5.copySync(skillsDir, path5.join(backupDir, "skills"));
208
+ }
209
+ const pluginsDir = path5.join(CLAUDE_HOME, "plugins");
210
+ for (const file of ["installed_plugins.json", "known_marketplaces.json"]) {
211
+ const src = path5.join(pluginsDir, file);
212
+ if (fs5.existsSync(src)) {
213
+ fs5.ensureDirSync(path5.join(backupDir, "plugins"));
214
+ fs5.copySync(src, path5.join(backupDir, "plugins", file));
215
+ }
216
+ }
217
+ logOk(`Backup created: ${backupDir}`);
218
+ return backupDir;
219
+ }
220
+
221
+ // src/core/plugins.ts
222
+ import { execFileSync } from "child_process";
223
+ function isNonNullObject(value) {
224
+ return typeof value === "object" && value !== null;
225
+ }
226
+ function execClaude(args) {
227
+ try {
228
+ execFileSync("claude", args, { stdio: "pipe" });
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+ function isClaudeAvailable() {
235
+ try {
236
+ execFileSync("claude", ["--version"], { stdio: "pipe" });
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+ function reinstallPlugins(marketplacesFile, pluginsFile, settingsFile) {
243
+ if (!isClaudeAvailable()) {
244
+ logError("claude CLI not found. Cannot reinstall plugins.");
245
+ return;
246
+ }
247
+ logInfo("Phase 1: Adding plugin marketplaces...");
248
+ const marketplaces = readJsonSafe(marketplacesFile);
249
+ if (isNonNullObject(marketplaces)) {
250
+ for (const [name, entry] of Object.entries(marketplaces)) {
251
+ if (!isNonNullObject(entry)) continue;
252
+ const source = entry.source;
253
+ if (!isNonNullObject(source)) continue;
254
+ let arg;
255
+ if (source.source === "github" && source.repo) {
256
+ arg = `github:${source.repo}`;
257
+ } else if (source.url) {
258
+ arg = source.url;
259
+ } else {
260
+ logWarn(`Unknown marketplace source for ${name}`);
261
+ continue;
262
+ }
263
+ if (execClaude(["plugin", "marketplace", "add", arg])) {
264
+ logOk(`Added marketplace: ${name}`);
265
+ } else {
266
+ logWarn(`Failed to add marketplace: ${name}`);
267
+ }
268
+ }
269
+ }
270
+ logInfo("Phase 2: Installing plugins...");
271
+ const plugins = readJsonSafe(pluginsFile);
272
+ if (isNonNullObject(plugins) && isNonNullObject(plugins.plugins)) {
273
+ for (const key of Object.keys(plugins.plugins)) {
274
+ if (execClaude(["plugin", "install", key])) {
275
+ logOk(`Installed plugin: ${key}`);
276
+ } else {
277
+ logWarn(`Failed to install plugin: ${key}`);
278
+ }
279
+ }
280
+ }
281
+ logInfo("Phase 3: Enabling plugins...");
282
+ const settings = readJsonSafe(settingsFile);
283
+ if (isNonNullObject(settings) && isNonNullObject(settings.enabledPlugins)) {
284
+ for (const [key, enabled] of Object.entries(settings.enabledPlugins)) {
285
+ if (!enabled) continue;
286
+ if (execClaude(["plugin", "enable", key])) {
287
+ logOk(`Enabled plugin: ${key}`);
288
+ } else {
289
+ logWarn(`Failed to enable plugin: ${key}`);
290
+ }
291
+ }
292
+ }
293
+ logOk("Plugin reinstallation complete");
294
+ }
295
+
296
+ // src/commands/import.ts
297
+ function cmdImport(options = {}) {
298
+ const configDir = getConfigDir();
299
+ if (!fs6.existsSync(configDir)) {
300
+ throw new Error(
301
+ `Config directory not found: ${configDir}
302
+ Run "vibe-sync export" first or "vibe-sync pull"`
303
+ );
304
+ }
305
+ backupExisting();
306
+ for (const file of SYNC_FILES) {
307
+ const src = path6.join(configDir, file);
308
+ if (fs6.existsSync(src)) {
309
+ fs6.ensureDirSync(path6.dirname(path6.join(CLAUDE_HOME, file)));
310
+ fs6.copySync(src, path6.join(CLAUDE_HOME, file));
311
+ logInfo(`Imported: ${file}`);
312
+ }
313
+ }
314
+ for (const dir of SYNC_DIRS) {
315
+ const src = path6.join(configDir, dir);
316
+ if (fs6.existsSync(src)) {
317
+ fs6.copySync(src, path6.join(CLAUDE_HOME, dir), { overwrite: true });
318
+ logInfo(`Imported: ${dir}/`);
319
+ }
320
+ }
321
+ importSkills(
322
+ path6.join(configDir, "skills"),
323
+ path6.join(CLAUDE_HOME, "skills")
324
+ );
325
+ const pluginsSrc = path6.join(configDir, "plugins");
326
+ const pluginsDest = path6.join(CLAUDE_HOME, "plugins");
327
+ for (const file of ["installed_plugins.json", "known_marketplaces.json"]) {
328
+ const src = path6.join(pluginsSrc, file);
329
+ if (fs6.existsSync(src)) {
330
+ fs6.ensureDirSync(pluginsDest);
331
+ fs6.copySync(src, path6.join(pluginsDest, file));
332
+ logInfo(`Imported: plugins/${file}`);
333
+ }
334
+ }
335
+ if (options.reinstallPlugins) {
336
+ reinstallPlugins(
337
+ path6.join(pluginsDest, "known_marketplaces.json"),
338
+ path6.join(pluginsDest, "installed_plugins.json"),
339
+ path6.join(CLAUDE_HOME, "settings.json")
340
+ );
341
+ }
342
+ logOk("Import complete");
343
+ }
344
+
345
+ // src/commands/status.ts
346
+ import path7 from "path";
347
+
348
+ // src/core/diff.ts
349
+ import fs7 from "fs-extra";
350
+ import { execFileSync as execFileSync2 } from "child_process";
351
+ import pc2 from "picocolors";
352
+ function showDiff(localPath, repoPath, label) {
353
+ if (!fs7.existsSync(localPath) && !fs7.existsSync(repoPath)) {
354
+ return false;
355
+ }
356
+ if (!fs7.existsSync(localPath)) {
357
+ console.log(pc2.yellow(` ${label}: exists in repo but not locally`));
358
+ return true;
359
+ }
360
+ if (!fs7.existsSync(repoPath)) {
361
+ console.log(pc2.yellow(` ${label}: exists locally but not in repo`));
362
+ return true;
363
+ }
364
+ const isDir = fs7.statSync(localPath).isDirectory();
365
+ try {
366
+ const args = isDir ? ["-rq", localPath, repoPath] : ["-q", localPath, repoPath];
367
+ execFileSync2("diff", args, { stdio: "pipe" });
368
+ return false;
369
+ } catch {
370
+ console.log(pc2.yellow(` ${label}: differs`));
371
+ if (!isDir) {
372
+ try {
373
+ const output = execFileSync2(
374
+ "diff",
375
+ ["--color=auto", localPath, repoPath],
376
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
377
+ );
378
+ if (output) console.log(output);
379
+ } catch (e) {
380
+ const err = e;
381
+ if (err.stdout) console.log(err.stdout);
382
+ }
383
+ }
384
+ return true;
385
+ }
386
+ }
387
+
388
+ // src/commands/status.ts
389
+ function cmdStatus() {
390
+ const configDir = getConfigDir();
391
+ let hasDiff = false;
392
+ for (const file of SYNC_FILES) {
393
+ const local = path7.join(CLAUDE_HOME, file);
394
+ const repo = path7.join(configDir, file);
395
+ if (showDiff(local, repo, file)) {
396
+ hasDiff = true;
397
+ }
398
+ }
399
+ for (const dir of SYNC_DIRS) {
400
+ const local = path7.join(CLAUDE_HOME, dir);
401
+ const repo = path7.join(configDir, dir);
402
+ if (showDiff(local, repo, `${dir}/`)) {
403
+ hasDiff = true;
404
+ }
405
+ }
406
+ const localSkills = path7.join(CLAUDE_HOME, "skills");
407
+ const repoSkills = path7.join(configDir, "skills");
408
+ if (showDiff(localSkills, repoSkills, "skills/")) {
409
+ hasDiff = true;
410
+ }
411
+ for (const file of ["installed_plugins.json", "known_marketplaces.json"]) {
412
+ const local = path7.join(CLAUDE_HOME, "plugins", file);
413
+ const repo = path7.join(configDir, "plugins", file);
414
+ if (showDiff(local, repo, `plugins/${file}`)) {
415
+ hasDiff = true;
416
+ }
417
+ }
418
+ if (!hasDiff) {
419
+ logOk("No differences found");
420
+ }
421
+ }
422
+
423
+ // src/core/git.ts
424
+ import { simpleGit } from "simple-git";
425
+ function createGit(baseDir) {
426
+ return simpleGit({ baseDir });
427
+ }
428
+ async function ensureGitRepo(git) {
429
+ const isRepo = await git.checkIsRepo();
430
+ if (!isRepo) {
431
+ await git.init();
432
+ logInfo("Initialized git repository");
433
+ }
434
+ }
435
+ async function hasRemote(git) {
436
+ try {
437
+ const remotes = await git.getRemotes();
438
+ return remotes.length > 0;
439
+ } catch {
440
+ return false;
441
+ }
442
+ }
443
+ async function commitAndPush(git) {
444
+ await ensureGitRepo(git);
445
+ await git.add("-A");
446
+ const status = await git.status();
447
+ if (status.isClean()) {
448
+ logInfo("No changes to commit");
449
+ } else {
450
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", "_");
451
+ const msg = `sync: update claude configs ${timestamp}`;
452
+ await git.commit(msg);
453
+ logOk(`Committed: ${msg}`);
454
+ }
455
+ if (await hasRemote(git)) {
456
+ const branch = (await git.branchLocal()).current || "main";
457
+ await git.push(["-u", "origin", branch]);
458
+ logOk("Pushed to remote");
459
+ } else {
460
+ logWarn("No remote configured. Run: git remote add origin <url>");
461
+ }
462
+ return !status.isClean();
463
+ }
464
+ async function pullFromRemote(git) {
465
+ if (!await hasRemote(git)) {
466
+ throw new Error("No remote configured. Run: git remote add origin <url>");
467
+ }
468
+ await git.pull();
469
+ logOk("Pulled from remote");
470
+ }
471
+
472
+ // src/commands/push.ts
473
+ async function cmdPush() {
474
+ cmdExport();
475
+ const git = createGit(SYNC_DIR);
476
+ await commitAndPush(git);
477
+ }
478
+
479
+ // src/commands/pull.ts
480
+ async function cmdPull(options = {}) {
481
+ const git = createGit(SYNC_DIR);
482
+ await pullFromRemote(git);
483
+ cmdImport({ reinstallPlugins: options.reinstallPlugins });
484
+ }
485
+
486
+ // src/commands/init.ts
487
+ import fs8 from "fs-extra";
488
+ import path8 from "path";
489
+ import readline from "readline/promises";
490
+ function createReadline() {
491
+ return readline.createInterface({
492
+ input: process.stdin,
493
+ output: process.stdout
494
+ });
495
+ }
496
+ async function cmdInit() {
497
+ if (isInitialized()) {
498
+ const git = createGit(SYNC_DIR);
499
+ const remotes = await git.getRemotes(true);
500
+ const origin = remotes.find((r) => r.name === "origin");
501
+ if (origin) {
502
+ logInfo(`Current remote: ${origin.refs.fetch}`);
503
+ } else {
504
+ logWarn("No remote configured");
505
+ }
506
+ const rl2 = createReadline();
507
+ try {
508
+ const url = await rl2.question("? New Git remote URL (leave empty to keep current): ");
509
+ const trimmedUrl = url.trim();
510
+ if (!trimmedUrl) {
511
+ logInfo("Remote unchanged");
512
+ return;
513
+ }
514
+ const gitUrlPattern = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
515
+ if (!gitUrlPattern.test(trimmedUrl)) {
516
+ logError("Invalid Git URL format. Expected https://, git@, ssh://, or git:// URL");
517
+ return;
518
+ }
519
+ if (origin) {
520
+ await git.remote(["set-url", "origin", trimmedUrl]);
521
+ } else {
522
+ await git.addRemote("origin", trimmedUrl);
523
+ }
524
+ logOk(`Remote updated: ${trimmedUrl}`);
525
+ } finally {
526
+ rl2.close();
527
+ }
528
+ return;
529
+ }
530
+ console.log("");
531
+ console.log("Welcome to vibe-sync! Let's set up config synchronization.");
532
+ console.log("");
533
+ const rl = createReadline();
534
+ try {
535
+ const url = await rl.question("? Git remote URL: ");
536
+ const trimmedUrl = url.trim();
537
+ if (!trimmedUrl) {
538
+ logError("URL cannot be empty");
539
+ return;
540
+ }
541
+ const gitUrlPattern = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
542
+ if (!gitUrlPattern.test(trimmedUrl)) {
543
+ logError("Invalid Git URL format. Expected https://, git@, ssh://, or git:// URL");
544
+ return;
545
+ }
546
+ fs8.ensureDirSync(SYNC_DIR);
547
+ fs8.writeFileSync(
548
+ path8.join(SYNC_DIR, ".gitignore"),
549
+ ".DS_Store\nThumbs.db\n"
550
+ );
551
+ const git = createGit(SYNC_DIR);
552
+ await git.init();
553
+ logOk("Initialized git repository");
554
+ await git.addRemote("origin", trimmedUrl);
555
+ logOk(`Remote added: ${trimmedUrl}`);
556
+ try {
557
+ await git.pull("origin", "main");
558
+ logOk("Pulled existing data from remote");
559
+ } catch {
560
+ try {
561
+ await git.pull("origin", "master");
562
+ logOk("Pulled existing data from remote");
563
+ } catch {
564
+ logInfo("No existing data on remote (new repository)");
565
+ }
566
+ }
567
+ console.log("");
568
+ logOk("Setup complete!");
569
+ console.log("");
570
+ console.log(" vibe-sync push Export configs and push to remote");
571
+ console.log(" vibe-sync pull Pull from remote and import configs");
572
+ console.log(" vibe-sync status Show diff between local and synced");
573
+ console.log("");
574
+ } finally {
575
+ rl.close();
576
+ }
577
+ }
578
+
579
+ // src/index.ts
580
+ var program = new Command();
581
+ program.name("vibe-sync").description("Sync AI coding tool configurations across machines via git").version("0.0.1").action(async () => {
582
+ if (!isInitialized()) {
583
+ await cmdInit();
584
+ } else {
585
+ cmdStatus();
586
+ }
587
+ });
588
+ program.command("init").description("Initialize sync repository").action(cmdInit);
589
+ program.command("export").description("Export configs from ~/.claude/ to sync repo").action(cmdExport);
590
+ program.command("import").description("Import configs from sync repo to ~/.claude/").option("--reinstall-plugins", "Reinstall plugins via claude CLI").action((opts) => {
591
+ cmdImport({ reinstallPlugins: opts.reinstallPlugins });
592
+ });
593
+ program.command("status").description("Show diff between local and synced configs").action(cmdStatus);
594
+ program.command("push").description("Export + git commit + git push").action(cmdPush);
595
+ program.command("pull").description("Git pull + import").option("--reinstall-plugins", "Reinstall plugins via claude CLI").action(async (opts) => {
596
+ await cmdPull({ reinstallPlugins: opts.reinstallPlugins });
597
+ });
598
+ async function run() {
599
+ try {
600
+ await program.parseAsync();
601
+ } catch (err) {
602
+ logError(err instanceof Error ? err.message : "An unexpected error occurred");
603
+ process.exit(1);
604
+ }
605
+ }
606
+ run();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "vibe-config-sync",
3
+ "version": "0.0.1",
4
+ "description": "Cross-platform CLI tool to sync AI coding tool configurations across machines via git",
5
+ "type": "module",
6
+ "bin": {
7
+ "vibe-sync": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "lint": "tsc --noEmit",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "keywords": [
24
+ "claude",
25
+ "claude-code",
26
+ "sync",
27
+ "config",
28
+ "cli"
29
+ ],
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "commander": "^13.1.0",
33
+ "fs-extra": "^11.2.0",
34
+ "picocolors": "^1.1.1",
35
+ "simple-git": "^3.27.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/fs-extra": "^11.0.4",
39
+ "@types/node": "^22.10.0",
40
+ "tsup": "^8.3.0",
41
+ "typescript": "^5.7.0",
42
+ "vitest": "^2.1.0"
43
+ }
44
+ }