syncdotfile 1.0.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 +137 -0
- package/index.js +645 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# π₯· SDF: Shadow Directory Framework
|
|
2
|
+
|
|
3
|
+
**Bring your AI agent's brain to any Git repository. Leave absolutely no trace.**
|
|
4
|
+
|
|
5
|
+
As AI coding agents (like Cursor, Aider, and Copilot Workspace) become more advanced, developers are building up extensive personal libraries of custom skills, prompt templates, and `agents.md` files.
|
|
6
|
+
|
|
7
|
+
But bringing your personal AI context into a shared team repository causes friction:
|
|
8
|
+
1. **Context Drift:** Copy-pasting your `agents.md` across 10 different repos means your agent's brain gets fragmented. Discover a new trick in Repo A? Repo B won't know about it.
|
|
9
|
+
2. **Repo Pollution:** Adding `.agent` folders and `.cursorrules` to shared codebases annoys teammates who don't use the same AI workflow.
|
|
10
|
+
|
|
11
|
+
**SDF solves both.** It is a zero-dependency Node.js CLI that dynamically projects your global AI skills into any local project invisibly.
|
|
12
|
+
|
|
13
|
+
## β¨ How It Works
|
|
14
|
+
|
|
15
|
+
SDF operates on three principles: **Symlinks**, an **Invisibility Cloak**, and **Stash/Restore**.
|
|
16
|
+
|
|
17
|
+
When you run `sdf mount`, the CLI creates symlinks at your project root pointing to files in your global registry (`~/.sdf-registry`). Your `AGENTS.md`, `.cursorrules`, `.github/copilot-instructions.md`, or any other context files appear exactly where every AI tool expects them.
|
|
18
|
+
|
|
19
|
+
If a file already exists locally (e.g., the project already has an `AGENTS.md`), SDF backs it up to a hidden `.sdf-stash` directory before replacing it with your global version. When you `sdf unmount`, every original file is restored exactly as it was.
|
|
20
|
+
|
|
21
|
+
All mounted files and the stash directory are added to `.git/info/exclude` β a local-only exclusion that never touches `.gitignore` and is never tracked by Git. Your agent gets the context it needs, Git ignores it entirely, and your teammates never even know it is there.
|
|
22
|
+
|
|
23
|
+
Because it uses symlinks, any improvements your agent makes to a file locally are instantly saved to your global registry and available in all your other projects.
|
|
24
|
+
|
|
25
|
+
## π Installation
|
|
26
|
+
|
|
27
|
+
Install globally via npm:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g syncdotfile
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## π οΈ Usage
|
|
34
|
+
|
|
35
|
+
### 1. Initialize your global registry
|
|
36
|
+
|
|
37
|
+
**Option A:** Start fresh.
|
|
38
|
+
```bash
|
|
39
|
+
sdf init
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Option B:** Clone your existing `dotagent` repo.
|
|
43
|
+
```bash
|
|
44
|
+
sdf init https://github.com/yourname/dotagent.git
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Populate `~/.sdf-registry` with the files you want mounted everywhere:
|
|
48
|
+
```
|
|
49
|
+
~/.sdf-registry/
|
|
50
|
+
βββ AGENTS.md
|
|
51
|
+
βββ CLAUDE.md
|
|
52
|
+
βββ .cursorrules
|
|
53
|
+
βββ .github/
|
|
54
|
+
β βββ copilot-instructions.md
|
|
55
|
+
βββ skills/
|
|
56
|
+
βββ debugging.md
|
|
57
|
+
βββ code-review.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. Mount your AI context
|
|
61
|
+
|
|
62
|
+
Navigate to any local Git repository and mount.
|
|
63
|
+
```bash
|
|
64
|
+
cd my-team-project
|
|
65
|
+
sdf mount
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If files collide, SDF prompts you interactively:
|
|
69
|
+
```
|
|
70
|
+
β AGENTS.md already exists in this project.
|
|
71
|
+
Local: 1284 bytes
|
|
72
|
+
Registry: 3502 bytes
|
|
73
|
+
[o]verwrite (stash original) [s]kip [d]iff >
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or use flags for non-interactive workflows:
|
|
77
|
+
```bash
|
|
78
|
+
sdf mount --force # Overwrite all conflicts (stashes originals)
|
|
79
|
+
sdf mount --skip-conflicts # Skip all conflicts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Check status
|
|
83
|
+
```bash
|
|
84
|
+
sdf status
|
|
85
|
+
```
|
|
86
|
+
```
|
|
87
|
+
π’ 3 files mounted:
|
|
88
|
+
AGENTS.md -> ~/.sdf-registry/AGENTS.md
|
|
89
|
+
CLAUDE.md -> ~/.sdf-registry/CLAUDE.md
|
|
90
|
+
.cursorrules -> ~/.sdf-registry/.cursorrules
|
|
91
|
+
π¦ 1 original stashed:
|
|
92
|
+
AGENTS.md (2026-03-17)
|
|
93
|
+
βΉ Registry git: main (clean)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 4. Unmount and clean up
|
|
97
|
+
|
|
98
|
+
Sever the symlinks and restore stashed originals.
|
|
99
|
+
```bash
|
|
100
|
+
sdf unmount
|
|
101
|
+
```
|
|
102
|
+
```
|
|
103
|
+
β Unlinked AGENTS.md
|
|
104
|
+
β Unlinked CLAUDE.md
|
|
105
|
+
β Unlinked .cursorrules
|
|
106
|
+
β Restored AGENTS.md from stash
|
|
107
|
+
β Unmounted: 3 symlinks removed, 1 original restored.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 5. Sync with Git
|
|
111
|
+
|
|
112
|
+
If you initialised your registry from a Git repo (like `dotagent`), SDF provides thin wrappers to keep it synced:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
sdf pull # Pull latest changes from remote
|
|
116
|
+
sdf push # Stage, commit, and push all local changes
|
|
117
|
+
sdf diff # Show uncommitted changes in the registry
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## π₯ SDF vs. Dotfile Managers (Chezmoi, Stow)
|
|
121
|
+
|
|
122
|
+
Dotfile managers are designed to permanently deploy system configurations (like `~/.zshrc`) to your home directory. SDF is designed to temporarily inject personalized AI context into individual, shared workspaces without polluting version control.
|
|
123
|
+
|
|
124
|
+
SDF doesn't copy files; it tethers them. It is a live brain connection for your AI tools.
|
|
125
|
+
|
|
126
|
+
## πΊοΈ Roadmap
|
|
127
|
+
|
|
128
|
+
See [ROADMAP.md](ROADMAP.md) for the full product roadmap, including planned features:
|
|
129
|
+
- **Selective mounting** β mount subsets with globs or exclude patterns
|
|
130
|
+
- **Profiles** β named file sets for different project types (Go, React, etc.)
|
|
131
|
+
- **Project context generation** β auto-generate project-specific instructions by scanning the codebase
|
|
132
|
+
- **Drift detection** β flag when local instructions are stale after registry updates
|
|
133
|
+
- **Multi-source registries** β layer team + personal repos
|
|
134
|
+
|
|
135
|
+
## π License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Paths
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const REGISTRY_DIR = path.join(os.homedir(), ".sdf-registry");
|
|
13
|
+
const STASH_DIR_NAME = ".sdf-stash";
|
|
14
|
+
const STASH_MANIFEST = "manifest.json";
|
|
15
|
+
const GIT_DIR = path.resolve(".git");
|
|
16
|
+
const GIT_EXCLUDE_FILE = path.join(GIT_DIR, "info", "exclude");
|
|
17
|
+
|
|
18
|
+
const SDF_MANAGED_COMMENT = "# sdf-managed";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// ANSI helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const color = {
|
|
24
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
25
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
26
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
27
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
28
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
29
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function success(msg) {
|
|
33
|
+
console.log(color.green(`β ${msg}`));
|
|
34
|
+
}
|
|
35
|
+
function warn(msg) {
|
|
36
|
+
console.log(color.yellow(`β ${msg}`));
|
|
37
|
+
}
|
|
38
|
+
function info(msg) {
|
|
39
|
+
console.log(color.cyan(`βΉ ${msg}`));
|
|
40
|
+
}
|
|
41
|
+
function error(msg) {
|
|
42
|
+
console.error(color.red(`β ${msg}`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function ensureDir(dir) {
|
|
49
|
+
if (!fs.existsSync(dir)) {
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requireGitRepo() {
|
|
55
|
+
if (!fs.existsSync(GIT_DIR) || !fs.statSync(GIT_DIR).isDirectory()) {
|
|
56
|
+
error("Must be run at the root of a Git repository.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireRegistry() {
|
|
62
|
+
if (!fs.existsSync(REGISTRY_DIR)) {
|
|
63
|
+
error("Global registry does not exist. Run `sdf init` first.");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function registryEntries(dir = REGISTRY_DIR, prefix = "") {
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
71
|
+
if (entry === ".git") continue;
|
|
72
|
+
const fullPath = path.join(dir, entry);
|
|
73
|
+
const relativePath = prefix ? path.join(prefix, entry) : entry;
|
|
74
|
+
const stat = fs.statSync(fullPath);
|
|
75
|
+
if (stat.isDirectory()) {
|
|
76
|
+
results.push(...registryEntries(fullPath, relativePath));
|
|
77
|
+
} else {
|
|
78
|
+
results.push(relativePath);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function gitCmd(args, cwd = REGISTRY_DIR) {
|
|
85
|
+
return execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: "pipe" }).trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRegistryAGitRepo() {
|
|
89
|
+
return fs.existsSync(path.join(REGISTRY_DIR, ".git"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireRegistryGit() {
|
|
93
|
+
requireRegistry();
|
|
94
|
+
if (!isRegistryAGitRepo()) {
|
|
95
|
+
error("Registry is not a Git repository. Use `sdf init <git-url>` to set one up.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Git exclude management
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function readExclude() {
|
|
104
|
+
ensureDir(path.join(GIT_DIR, "info"));
|
|
105
|
+
if (!fs.existsSync(GIT_EXCLUDE_FILE)) return [];
|
|
106
|
+
return fs.readFileSync(GIT_EXCLUDE_FILE, "utf-8").split("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeExclude(lines) {
|
|
110
|
+
const content = lines.join("\n");
|
|
111
|
+
fs.writeFileSync(GIT_EXCLUDE_FILE, content.endsWith("\n") ? content : content + "\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function addExclusion(filename) {
|
|
115
|
+
const lines = readExclude();
|
|
116
|
+
if (lines.some((l) => l.trim() === filename)) return false;
|
|
117
|
+
const needsNewline = lines.length > 0 && lines[lines.length - 1] !== "";
|
|
118
|
+
if (needsNewline) lines.push("");
|
|
119
|
+
lines.push(SDF_MANAGED_COMMENT);
|
|
120
|
+
lines.push(filename);
|
|
121
|
+
writeExclude(lines);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeExclusion(filename) {
|
|
126
|
+
const lines = readExclude();
|
|
127
|
+
const filtered = [];
|
|
128
|
+
for (let i = 0; i < lines.length; i++) {
|
|
129
|
+
if (lines[i].trim() === filename) {
|
|
130
|
+
if (filtered.length > 0 && filtered[filtered.length - 1].trim() === SDF_MANAGED_COMMENT) {
|
|
131
|
+
filtered.pop();
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
filtered.push(lines[i]);
|
|
136
|
+
}
|
|
137
|
+
const cleaned = filtered.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
138
|
+
fs.writeFileSync(GIT_EXCLUDE_FILE, cleaned.endsWith("\n") ? cleaned : cleaned + "\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Stash management
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
function stashDir() {
|
|
145
|
+
return path.resolve(STASH_DIR_NAME);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function manifestPath() {
|
|
149
|
+
return path.join(stashDir(), STASH_MANIFEST);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readManifest() {
|
|
153
|
+
const mp = manifestPath();
|
|
154
|
+
if (!fs.existsSync(mp)) return {};
|
|
155
|
+
return JSON.parse(fs.readFileSync(mp, "utf-8"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeManifest(manifest) {
|
|
159
|
+
ensureDir(stashDir());
|
|
160
|
+
fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2) + "\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stashFile(filePath) {
|
|
164
|
+
const sd = stashDir();
|
|
165
|
+
const stashedPath = path.join(sd, filePath);
|
|
166
|
+
ensureDir(path.dirname(stashedPath));
|
|
167
|
+
|
|
168
|
+
const sourcePath = path.resolve(filePath);
|
|
169
|
+
fs.copyFileSync(sourcePath, stashedPath);
|
|
170
|
+
|
|
171
|
+
const manifest = readManifest();
|
|
172
|
+
manifest[filePath] = {
|
|
173
|
+
stashedAt: new Date().toISOString(),
|
|
174
|
+
size: fs.statSync(stashedPath).size,
|
|
175
|
+
};
|
|
176
|
+
writeManifest(manifest);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function restoreFile(filePath) {
|
|
180
|
+
const stashedPath = path.join(stashDir(), filePath);
|
|
181
|
+
const targetPath = path.resolve(filePath);
|
|
182
|
+
|
|
183
|
+
if (!fs.existsSync(stashedPath)) return false;
|
|
184
|
+
|
|
185
|
+
ensureDir(path.dirname(targetPath));
|
|
186
|
+
fs.copyFileSync(stashedPath, targetPath);
|
|
187
|
+
|
|
188
|
+
const manifest = readManifest();
|
|
189
|
+
delete manifest[filePath];
|
|
190
|
+
writeManifest(manifest);
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cleanupStash() {
|
|
196
|
+
const sd = stashDir();
|
|
197
|
+
if (!fs.existsSync(sd)) return;
|
|
198
|
+
const manifest = readManifest();
|
|
199
|
+
if (Object.keys(manifest).length === 0) {
|
|
200
|
+
fs.rmSync(sd, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Interactive prompt
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
function ask(question) {
|
|
208
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
rl.question(question, (answer) => {
|
|
211
|
+
rl.close();
|
|
212
|
+
resolve(answer.trim().toLowerCase());
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Commands
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function init() {
|
|
222
|
+
const url = process.argv[3];
|
|
223
|
+
|
|
224
|
+
if (url) {
|
|
225
|
+
if (fs.existsSync(REGISTRY_DIR)) {
|
|
226
|
+
error(`Registry already exists at ${REGISTRY_DIR}. Remove it first to re-clone.`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
info(`Cloning ${url} into ${REGISTRY_DIR}...`);
|
|
230
|
+
try {
|
|
231
|
+
execSync(`git clone ${url} "${REGISTRY_DIR}"`, { stdio: "inherit" });
|
|
232
|
+
} catch {
|
|
233
|
+
error("Git clone failed.");
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
success(`Registry cloned from ${url}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const created = !fs.existsSync(REGISTRY_DIR);
|
|
241
|
+
ensureDir(REGISTRY_DIR);
|
|
242
|
+
|
|
243
|
+
if (created) {
|
|
244
|
+
success(`Created global registry at ${REGISTRY_DIR}`);
|
|
245
|
+
} else {
|
|
246
|
+
info(`Registry already exists at ${REGISTRY_DIR}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const placeholder = path.join(REGISTRY_DIR, "default-agent.md");
|
|
250
|
+
if (!fs.existsSync(placeholder)) {
|
|
251
|
+
fs.writeFileSync(
|
|
252
|
+
placeholder,
|
|
253
|
+
[
|
|
254
|
+
"# Default Agent",
|
|
255
|
+
"",
|
|
256
|
+
"You are a helpful coding assistant.",
|
|
257
|
+
"",
|
|
258
|
+
"## Guidelines",
|
|
259
|
+
"",
|
|
260
|
+
"- Write clean, well-structured code.",
|
|
261
|
+
"- Prefer simple solutions over clever ones.",
|
|
262
|
+
"- Always explain your reasoning.",
|
|
263
|
+
"",
|
|
264
|
+
].join("\n"),
|
|
265
|
+
);
|
|
266
|
+
success(`Created placeholder prompt: ${placeholder}`);
|
|
267
|
+
} else {
|
|
268
|
+
info("Placeholder prompt already exists β skipped.");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
success("Registry initialised. Add your prompt files to ~/.sdf-registry (or use `sdf init <url>` to clone your dotagent repo).");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function mount() {
|
|
275
|
+
requireGitRepo();
|
|
276
|
+
requireRegistry();
|
|
277
|
+
|
|
278
|
+
const entries = registryEntries();
|
|
279
|
+
if (entries.length === 0) {
|
|
280
|
+
warn("Registry is empty β nothing to mount.");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const isWin = os.platform() === "win32";
|
|
285
|
+
const forceAll = process.argv.includes("--force");
|
|
286
|
+
const skipAll = process.argv.includes("--skip-conflicts");
|
|
287
|
+
let linked = 0;
|
|
288
|
+
let stashed = 0;
|
|
289
|
+
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const source = path.join(REGISTRY_DIR, entry);
|
|
292
|
+
const target = path.resolve(entry);
|
|
293
|
+
|
|
294
|
+
// Already a symlink pointing to our registry
|
|
295
|
+
if (fs.existsSync(target)) {
|
|
296
|
+
const stat = fs.lstatSync(target);
|
|
297
|
+
if (stat.isSymbolicLink()) {
|
|
298
|
+
const linkTarget = fs.readlinkSync(target);
|
|
299
|
+
if (linkTarget === source) {
|
|
300
|
+
info(`Already linked: ${entry}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Collision: a real file exists
|
|
306
|
+
let action = "skip";
|
|
307
|
+
|
|
308
|
+
if (forceAll) {
|
|
309
|
+
action = "overwrite";
|
|
310
|
+
} else if (skipAll) {
|
|
311
|
+
action = "skip";
|
|
312
|
+
} else if (process.stdin.isTTY) {
|
|
313
|
+
console.log("");
|
|
314
|
+
warn(`${color.bold(entry)} already exists in this project.`);
|
|
315
|
+
|
|
316
|
+
const localSize = fs.statSync(target).size;
|
|
317
|
+
const registrySize = fs.statSync(source).size;
|
|
318
|
+
console.log(` Local: ${localSize} bytes`);
|
|
319
|
+
console.log(` Registry: ${registrySize} bytes`);
|
|
320
|
+
|
|
321
|
+
const answer = await ask(
|
|
322
|
+
` ${color.cyan("[o]")}verwrite (stash original) ${color.cyan("[s]")}kip ${color.cyan("[d]")}iff > `,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (answer === "d" || answer === "diff") {
|
|
326
|
+
try {
|
|
327
|
+
const diff = execSync(
|
|
328
|
+
`diff --unified "${target}" "${source}"`,
|
|
329
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
|
|
330
|
+
);
|
|
331
|
+
console.log(diff || " (files are identical)");
|
|
332
|
+
} catch (e) {
|
|
333
|
+
console.log(e.stdout || " (binary or incomparable files)");
|
|
334
|
+
}
|
|
335
|
+
const answer2 = await ask(
|
|
336
|
+
` ${color.cyan("[o]")}verwrite (stash original) ${color.cyan("[s]")}kip > `,
|
|
337
|
+
);
|
|
338
|
+
if (answer2 === "o" || answer2 === "overwrite") action = "overwrite";
|
|
339
|
+
} else if (answer === "o" || answer === "overwrite") {
|
|
340
|
+
action = "overwrite";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (action === "skip") {
|
|
345
|
+
info(`Skipped ${entry}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Stash the original before overwriting
|
|
350
|
+
stashFile(entry);
|
|
351
|
+
success(`Stashed original ${entry} β ${STASH_DIR_NAME}/${entry}`);
|
|
352
|
+
|
|
353
|
+
// Remove the original (file or directory)
|
|
354
|
+
const lstat = fs.lstatSync(target);
|
|
355
|
+
if (lstat.isDirectory()) {
|
|
356
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
357
|
+
} else {
|
|
358
|
+
fs.unlinkSync(target);
|
|
359
|
+
}
|
|
360
|
+
stashed++;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Create parent directories if needed (for nested registry files like .github/copilot-instructions.md)
|
|
364
|
+
ensureDir(path.dirname(target));
|
|
365
|
+
|
|
366
|
+
const stat = fs.statSync(source);
|
|
367
|
+
const symlinkType = stat.isDirectory() ? (isWin ? "junction" : "dir") : "file";
|
|
368
|
+
|
|
369
|
+
fs.symlinkSync(source, target, symlinkType);
|
|
370
|
+
|
|
371
|
+
// Exclude the top-level entry (for nested paths, exclude the top dir)
|
|
372
|
+
const topLevel = entry.includes(path.sep) ? entry.split(path.sep)[0] : entry;
|
|
373
|
+
addExclusion(topLevel);
|
|
374
|
+
|
|
375
|
+
success(`Linked ${entry}`);
|
|
376
|
+
linked++;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Exclude stash dir itself
|
|
380
|
+
if (fs.existsSync(stashDir())) {
|
|
381
|
+
addExclusion(STASH_DIR_NAME);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (linked === 0) {
|
|
385
|
+
info("All registry entries already linked or skipped.");
|
|
386
|
+
} else {
|
|
387
|
+
const stashMsg = stashed > 0 ? ` (${stashed} original${stashed === 1 ? "" : "s"} stashed)` : "";
|
|
388
|
+
success(`Mounted ${linked} file${linked === 1 ? "" : "s"} from registry.${stashMsg}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function unmount() {
|
|
393
|
+
requireGitRepo();
|
|
394
|
+
|
|
395
|
+
const cwd = process.cwd();
|
|
396
|
+
let removed = 0;
|
|
397
|
+
|
|
398
|
+
// Find and remove all symlinks pointing to the registry (including nested)
|
|
399
|
+
function removeSymlinks(dir) {
|
|
400
|
+
if (!fs.existsSync(dir)) return;
|
|
401
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
402
|
+
const fullPath = path.join(dir, entry);
|
|
403
|
+
const stat = fs.lstatSync(fullPath);
|
|
404
|
+
|
|
405
|
+
if (stat.isSymbolicLink()) {
|
|
406
|
+
const linkTarget = fs.readlinkSync(fullPath);
|
|
407
|
+
if (linkTarget.startsWith(REGISTRY_DIR)) {
|
|
408
|
+
const relative = path.relative(cwd, fullPath);
|
|
409
|
+
fs.unlinkSync(fullPath);
|
|
410
|
+
success(`Unlinked ${relative}`);
|
|
411
|
+
removed++;
|
|
412
|
+
}
|
|
413
|
+
} else if (stat.isDirectory() && entry !== ".git" && entry !== STASH_DIR_NAME && entry !== "node_modules") {
|
|
414
|
+
removeSymlinks(fullPath);
|
|
415
|
+
// Clean up empty parent directories left behind
|
|
416
|
+
try {
|
|
417
|
+
const remaining = fs.readdirSync(fullPath);
|
|
418
|
+
if (remaining.length === 0) fs.rmdirSync(fullPath);
|
|
419
|
+
} catch {
|
|
420
|
+
// ignore
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
removeSymlinks(cwd);
|
|
427
|
+
|
|
428
|
+
// Restore stashed files
|
|
429
|
+
const manifest = readManifest();
|
|
430
|
+
const stashedFiles = Object.keys(manifest);
|
|
431
|
+
let restored = 0;
|
|
432
|
+
|
|
433
|
+
for (const filePath of stashedFiles) {
|
|
434
|
+
if (restoreFile(filePath)) {
|
|
435
|
+
success(`Restored ${filePath} from stash`);
|
|
436
|
+
restored++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
cleanupStash();
|
|
441
|
+
|
|
442
|
+
// Clean git exclude of all sdf-managed entries
|
|
443
|
+
if (fs.existsSync(GIT_EXCLUDE_FILE)) {
|
|
444
|
+
const lines = readExclude();
|
|
445
|
+
const filtered = [];
|
|
446
|
+
for (let i = 0; i < lines.length; i++) {
|
|
447
|
+
if (
|
|
448
|
+
i + 1 < lines.length &&
|
|
449
|
+
lines[i].trim() === SDF_MANAGED_COMMENT
|
|
450
|
+
) {
|
|
451
|
+
// Check if next line is an sdf-managed entry β skip both
|
|
452
|
+
i++;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (lines[i].trim() === SDF_MANAGED_COMMENT) continue;
|
|
456
|
+
filtered.push(lines[i]);
|
|
457
|
+
}
|
|
458
|
+
const cleaned = filtered.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
459
|
+
fs.writeFileSync(GIT_EXCLUDE_FILE, cleaned.endsWith("\n") ? cleaned : cleaned + "\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (removed === 0 && restored === 0) {
|
|
463
|
+
info("No SDF symlinks or stashed files found.");
|
|
464
|
+
} else {
|
|
465
|
+
const parts = [];
|
|
466
|
+
if (removed > 0) parts.push(`${removed} symlink${removed === 1 ? "" : "s"} removed`);
|
|
467
|
+
if (restored > 0) parts.push(`${restored} original${restored === 1 ? "" : "s"} restored`);
|
|
468
|
+
success(`Unmounted: ${parts.join(", ")}.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function status() {
|
|
473
|
+
const cwd = process.cwd();
|
|
474
|
+
const mounted = [];
|
|
475
|
+
|
|
476
|
+
function findMounted(dir) {
|
|
477
|
+
if (!fs.existsSync(dir)) return;
|
|
478
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
479
|
+
const fullPath = path.join(dir, entry);
|
|
480
|
+
const stat = fs.lstatSync(fullPath);
|
|
481
|
+
|
|
482
|
+
if (stat.isSymbolicLink()) {
|
|
483
|
+
const linkTarget = fs.readlinkSync(fullPath);
|
|
484
|
+
if (linkTarget.startsWith(REGISTRY_DIR)) {
|
|
485
|
+
mounted.push(path.relative(cwd, fullPath));
|
|
486
|
+
}
|
|
487
|
+
} else if (stat.isDirectory() && entry !== ".git" && entry !== STASH_DIR_NAME && entry !== "node_modules") {
|
|
488
|
+
findMounted(fullPath);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
findMounted(cwd);
|
|
494
|
+
|
|
495
|
+
if (mounted.length > 0) {
|
|
496
|
+
console.log(color.green(`π’ ${mounted.length} file${mounted.length === 1 ? "" : "s"} mounted:`));
|
|
497
|
+
for (const f of mounted) {
|
|
498
|
+
console.log(` ${color.cyan(f)} -> ~/.sdf-registry/${f}`);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
console.log("βͺοΈ No SDF symlinks detected in this directory.");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Show stash info
|
|
505
|
+
const manifest = readManifest();
|
|
506
|
+
const stashedFiles = Object.keys(manifest);
|
|
507
|
+
if (stashedFiles.length > 0) {
|
|
508
|
+
console.log(color.yellow(`π¦ ${stashedFiles.length} original${stashedFiles.length === 1 ? "" : "s"} stashed:`));
|
|
509
|
+
for (const f of stashedFiles) {
|
|
510
|
+
console.log(` ${color.dim(f)} (${manifest[f].stashedAt.slice(0, 10)})`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (isRegistryAGitRepo()) {
|
|
515
|
+
try {
|
|
516
|
+
const branch = gitCmd("rev-parse --abbrev-ref HEAD");
|
|
517
|
+
const dirty = gitCmd("status --porcelain");
|
|
518
|
+
const label = dirty ? color.yellow("(uncommitted changes)") : color.green("(clean)");
|
|
519
|
+
info(`Registry git: ${branch} ${label}`);
|
|
520
|
+
} catch {
|
|
521
|
+
// not a concern if this fails
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function pull() {
|
|
527
|
+
requireRegistryGit();
|
|
528
|
+
info("Pulling latest from remote...");
|
|
529
|
+
try {
|
|
530
|
+
const output = gitCmd("pull");
|
|
531
|
+
console.log(color.dim(output));
|
|
532
|
+
success("Registry updated.");
|
|
533
|
+
} catch (e) {
|
|
534
|
+
error(`Pull failed: ${e.message}`);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function push() {
|
|
540
|
+
requireRegistryGit();
|
|
541
|
+
|
|
542
|
+
const dirty = gitCmd("status --porcelain");
|
|
543
|
+
if (!dirty) {
|
|
544
|
+
info("Nothing to push β registry is clean.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
info("Staging all changes...");
|
|
549
|
+
gitCmd("add -A");
|
|
550
|
+
|
|
551
|
+
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
552
|
+
const msg = `sdf: sync ${timestamp}`;
|
|
553
|
+
gitCmd(`commit -m "${msg}"`);
|
|
554
|
+
success(`Committed: ${msg}`);
|
|
555
|
+
|
|
556
|
+
info("Pushing to remote...");
|
|
557
|
+
try {
|
|
558
|
+
const output = gitCmd("push");
|
|
559
|
+
if (output) console.log(color.dim(output));
|
|
560
|
+
success("Registry pushed.");
|
|
561
|
+
} catch (e) {
|
|
562
|
+
error(`Push failed: ${e.message}`);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function diff() {
|
|
568
|
+
requireRegistryGit();
|
|
569
|
+
const output = gitCmd("diff");
|
|
570
|
+
const untrackedRaw = gitCmd("ls-files --others --exclude-standard");
|
|
571
|
+
const untracked = untrackedRaw ? untrackedRaw.split("\n") : [];
|
|
572
|
+
|
|
573
|
+
if (!output && untracked.length === 0) {
|
|
574
|
+
info("No local changes in registry.");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (output) console.log(output);
|
|
579
|
+
if (untracked.length > 0) {
|
|
580
|
+
console.log(color.yellow("\nUntracked files:"));
|
|
581
|
+
for (const f of untracked) {
|
|
582
|
+
console.log(` ${color.cyan(f)}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function usage() {
|
|
588
|
+
console.log(`
|
|
589
|
+
${color.bold("sdf")} β Shadow Directory Framework
|
|
590
|
+
|
|
591
|
+
${color.cyan("Usage:")}
|
|
592
|
+
sdf init [url] Initialise registry, or clone from a repo (e.g. your dotagent repo)
|
|
593
|
+
sdf mount Symlink registry files into the current Git repo root
|
|
594
|
+
sdf unmount Remove SDF symlinks, restore stashed originals
|
|
595
|
+
sdf status Show mounted files, stashed originals, and registry git state
|
|
596
|
+
|
|
597
|
+
${color.cyan("Mount options:")}
|
|
598
|
+
--force Overwrite all conflicts without prompting (stashes originals)
|
|
599
|
+
--skip-conflicts Skip all conflicts without prompting
|
|
600
|
+
|
|
601
|
+
${color.cyan("Git sync:")}
|
|
602
|
+
sdf pull Pull latest changes into the registry
|
|
603
|
+
sdf push Commit and push local registry changes
|
|
604
|
+
sdf diff Show uncommitted changes in the registry
|
|
605
|
+
|
|
606
|
+
${color.cyan("Example:")}
|
|
607
|
+
sdf init https://github.com/yourname/dotagent.git
|
|
608
|
+
cd my-project && sdf mount
|
|
609
|
+
`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Router
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
const command = process.argv[2];
|
|
616
|
+
|
|
617
|
+
switch (command) {
|
|
618
|
+
case "init":
|
|
619
|
+
init();
|
|
620
|
+
break;
|
|
621
|
+
case "mount":
|
|
622
|
+
mount();
|
|
623
|
+
break;
|
|
624
|
+
case "unmount":
|
|
625
|
+
unmount();
|
|
626
|
+
break;
|
|
627
|
+
case "status":
|
|
628
|
+
status();
|
|
629
|
+
break;
|
|
630
|
+
case "pull":
|
|
631
|
+
pull();
|
|
632
|
+
break;
|
|
633
|
+
case "push":
|
|
634
|
+
push();
|
|
635
|
+
break;
|
|
636
|
+
case "diff":
|
|
637
|
+
diff();
|
|
638
|
+
break;
|
|
639
|
+
default:
|
|
640
|
+
if (command) {
|
|
641
|
+
error(`Unknown command: ${command}`);
|
|
642
|
+
}
|
|
643
|
+
usage();
|
|
644
|
+
break;
|
|
645
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "syncdotfile",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shadow Directory Framework β project AI agent prompts via symlinks, invisible to Git.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sdf": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cli",
|
|
14
|
+
"symlink",
|
|
15
|
+
"dotfiles",
|
|
16
|
+
"ai",
|
|
17
|
+
"agents",
|
|
18
|
+
"cursor",
|
|
19
|
+
"copilot",
|
|
20
|
+
"codex",
|
|
21
|
+
"claude",
|
|
22
|
+
"agentic"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/NicholasCullenCooper/syncdotfile.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/NicholasCullenCooper/syncdotfile#readme",
|
|
29
|
+
"author": "Nicholas Cullen Cooper",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
}
|
|
34
|
+
}
|