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 +127 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +606 -0
- package/package.json +44 -0
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
|
package/dist/index.d.ts
ADDED
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
|
+
}
|