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.
Files changed (3) hide show
  1. package/README.md +137 -0
  2. package/index.js +645 -0
  3. 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
+ }