@dujavi/ai-md 0.4.0 → 0.6.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/lib/link.js ADDED
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+ const {
7
+ defaultLinkMode,
8
+ isWsl,
9
+ looksLikeWindowsPath,
10
+ pathsEqual,
11
+ normalizePath,
12
+ } = require("./config-paths");
13
+
14
+ function ensureDir(p) {
15
+ fs.mkdirSync(p, { recursive: true });
16
+ }
17
+
18
+ function linkState(linkPath, expected) {
19
+ let stat;
20
+ try {
21
+ stat = fs.lstatSync(linkPath);
22
+ } catch {
23
+ return { path: linkPath, state: "missing", target: null, expected };
24
+ }
25
+ if (stat.isSymbolicLink()) {
26
+ const target = fs.readlinkSync(linkPath);
27
+ const absTarget = path.isAbsolute(target)
28
+ ? target
29
+ : path.resolve(path.dirname(linkPath), target);
30
+ if (pathsEqual(absTarget, expected) || target === expected) {
31
+ return { path: linkPath, state: "ok", target, expected, mode: "symlink" };
32
+ }
33
+ return {
34
+ path: linkPath,
35
+ state: "wrong_target",
36
+ target,
37
+ expected,
38
+ mode: "symlink",
39
+ };
40
+ }
41
+ if (stat.isDirectory()) {
42
+ return {
43
+ path: linkPath,
44
+ state: "directory",
45
+ target: null,
46
+ expected,
47
+ mode: "copy_or_real",
48
+ };
49
+ }
50
+ return { path: linkPath, state: "not_symlink", target: null, expected };
51
+ }
52
+
53
+ function trySymlink(target, link, type) {
54
+ fs.symlinkSync(target, link, type);
55
+ }
56
+
57
+ /**
58
+ * Platform-aware directory link: symlink | junction | copy
59
+ */
60
+ function linkPath(
61
+ target,
62
+ link,
63
+ { force = false, dryRun = false, linkMode = null } = {}
64
+ ) {
65
+ const absTarget = path.resolve(target);
66
+ ensureDir(path.dirname(link));
67
+ ensureDir(absTarget);
68
+
69
+ const mode = linkMode || defaultLinkMode();
70
+ const state = linkState(link, absTarget);
71
+
72
+ if (mode !== "copy" && state.state === "ok") {
73
+ return { path: link, action: "ok", target: absTarget, mode };
74
+ }
75
+
76
+ if (state.state === "directory" && mode !== "copy" && !force) {
77
+ const err = new Error(
78
+ `${link} exists as a real directory (use --force to replace, or --link-mode copy)`
79
+ );
80
+ err.code = "EEXIST";
81
+ throw err;
82
+ }
83
+
84
+ if (state.state === "not_symlink" && !force && mode !== "copy") {
85
+ const err = new Error(
86
+ `${link} exists and is not a symlink (use --force to replace)`
87
+ );
88
+ err.code = "EEXIST";
89
+ throw err;
90
+ }
91
+
92
+ if (dryRun) {
93
+ return {
94
+ path: link,
95
+ action: state.state === "missing" ? "would_link" : "would_repair",
96
+ target: absTarget,
97
+ mode,
98
+ };
99
+ }
100
+
101
+ if (mode === "copy") {
102
+ return syncCopyTree(absTarget, link, { force });
103
+ }
104
+
105
+ if (fs.existsSync(link) || state.state !== "missing") {
106
+ try {
107
+ fs.rmSync(link, { recursive: true, force: true });
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ }
112
+
113
+ const attempts =
114
+ process.platform === "win32"
115
+ ? mode === "junction"
116
+ ? ["junction", "dir"]
117
+ : [mode === "symlink" ? "dir" : mode, "junction"]
118
+ : ["dir"];
119
+
120
+ let lastErr = null;
121
+ for (const t of attempts) {
122
+ try {
123
+ trySymlink(absTarget, link, t);
124
+ return {
125
+ path: link,
126
+ action: state.state === "missing" ? "linked" : "repaired",
127
+ target: absTarget,
128
+ mode: t === "junction" ? "junction" : "symlink",
129
+ };
130
+ } catch (e) {
131
+ lastErr = e;
132
+ }
133
+ }
134
+
135
+ // Fallback to copy
136
+ const copied = syncCopyTree(absTarget, link, { force: true });
137
+ return {
138
+ ...copied,
139
+ warning: `symlink failed (${lastErr && lastErr.message}); used copy`,
140
+ mode: "copy",
141
+ };
142
+ }
143
+
144
+ function syncCopyTree(src, dest, { force = false } = {}) {
145
+ ensureDir(dest);
146
+ // Remove dest contents that aren't in src
147
+ if (fs.existsSync(dest)) {
148
+ for (const name of fs.readdirSync(dest)) {
149
+ if (name === ".ai-md-copy-marker") continue;
150
+ const s = path.join(src, name);
151
+ const d = path.join(dest, name);
152
+ if (!fs.existsSync(s)) {
153
+ fs.rmSync(d, { recursive: true, force: true });
154
+ }
155
+ }
156
+ }
157
+ fs.cpSync(src, dest, { recursive: true, force: true });
158
+ fs.writeFileSync(
159
+ path.join(dest, ".ai-md-copy-marker"),
160
+ JSON.stringify({ src: normalizePath(src), at: new Date().toISOString() }) +
161
+ "\n"
162
+ );
163
+ return { path: dest, action: "copied", target: src, mode: "copy" };
164
+ }
165
+
166
+ function wslWarnings(cfg, harnessDefs) {
167
+ const warnings = [];
168
+ if (!isWsl()) return warnings;
169
+ warnings.push(
170
+ "Running under WSL: run ai-md in the same environment as the IDE/CLI that consumes links (Windows Cursor needs Windows ai-md)."
171
+ );
172
+ for (const h of harnessDefs || []) {
173
+ for (const p of [h.skills, h.rules]) {
174
+ if (p && looksLikeWindowsPath(p)) {
175
+ warnings.push(
176
+ `Harness ${h.id} path looks Windows-native under WSL: ${p}`
177
+ );
178
+ }
179
+ }
180
+ }
181
+ return warnings;
182
+ }
183
+
184
+ function hashFile(filePath) {
185
+ const h = crypto.createHash("sha256");
186
+ h.update(fs.readFileSync(filePath));
187
+ return h.digest("hex");
188
+ }
189
+
190
+ function walkFiles(root, base = root, out = []) {
191
+ if (!fs.existsSync(root)) return out;
192
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
193
+ if (ent.name === ".build-manifest.json" || ent.name === ".ai-md-copy-marker") {
194
+ continue;
195
+ }
196
+ if (ent.name.startsWith(".") && ent.isDirectory()) continue;
197
+ const full = path.join(root, ent.name);
198
+ const rel = path.relative(base, full).split(path.sep).join("/");
199
+ if (ent.isDirectory()) walkFiles(full, base, out);
200
+ else if (ent.isFile()) out.push({ rel, full });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ function fingerprintTree(root) {
206
+ const files = walkFiles(root);
207
+ const map = {};
208
+ for (const f of files) {
209
+ map[f.rel] = hashFile(f.full);
210
+ }
211
+ return map;
212
+ }
213
+
214
+ module.exports = {
215
+ ensureDir,
216
+ linkPath,
217
+ linkState,
218
+ syncCopyTree,
219
+ wslWarnings,
220
+ fingerprintTree,
221
+ walkFiles,
222
+ hashFile,
223
+ defaultLinkMode,
224
+ };
package/lib/rescue.js ADDED
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { distRoot, distDirty, agentRoots } = require("./build");
6
+ const { selectHarnesses, ensureAgentSourceDirs } = require("./harnesses");
7
+ const { ensureDir, walkFiles, hashFile } = require("./link");
8
+ const { readManifest } = require("./build");
9
+
10
+ /**
11
+ * Promote dirty dist files into agents/<id>/ (not shared/).
12
+ */
13
+ function runRescue(cfg, opts = {}) {
14
+ const { agents, dryRun = false, forceLink = false } = opts;
15
+ const { selected, skipped, warnings } = selectHarnesses(cfg, agents, {
16
+ forceLink: true,
17
+ includeUninstalled: true,
18
+ });
19
+
20
+ const actions = [];
21
+ const byDist = new Map();
22
+ for (const h of selected) {
23
+ if (!byDist.has(h.distId)) byDist.set(h.distId, h);
24
+ }
25
+
26
+ for (const h of byDist.values()) {
27
+ const distDir = distRoot(cfg, h.distId);
28
+ const dirty = distDirty(distDir);
29
+ if (!dirty.dirty) {
30
+ actions.push({ id: h.id, distId: h.distId, action: "clean" });
31
+ continue;
32
+ }
33
+ ensureAgentSourceDirs(cfg, h.distId);
34
+ const agent = agentRoots(cfg, h.distId);
35
+ const manifest = readManifest(distDir) || { files: {} };
36
+
37
+ for (const rel of dirty.files) {
38
+ const src = path.join(distDir, rel);
39
+ if (!fs.existsSync(src)) continue;
40
+ let dest;
41
+ if (rel.startsWith("rules/")) {
42
+ dest = path.join(agent.rules, path.basename(rel));
43
+ } else if (rel.startsWith("skills/")) {
44
+ // skills/name/...
45
+ const parts = rel.split("/");
46
+ const skillName = parts[1];
47
+ if (!skillName) continue;
48
+ if (parts.length === 2 || fs.statSync(src).isDirectory()) {
49
+ dest = path.join(agent.skills, skillName);
50
+ if (!dryRun) {
51
+ if (fs.statSync(path.join(distDir, "skills", skillName)).isDirectory()) {
52
+ fs.cpSync(path.join(distDir, "skills", skillName), dest, {
53
+ recursive: true,
54
+ force: true,
55
+ });
56
+ }
57
+ }
58
+ actions.push({
59
+ id: h.id,
60
+ action: dryRun ? "would_rescue_skill" : "rescued_skill",
61
+ from: rel,
62
+ to: dest,
63
+ });
64
+ continue;
65
+ }
66
+ dest = path.join(agent.skills, parts.slice(1).join("/"));
67
+ } else {
68
+ continue;
69
+ }
70
+ if (dryRun) {
71
+ actions.push({
72
+ id: h.id,
73
+ action: "would_rescue",
74
+ from: rel,
75
+ to: dest,
76
+ });
77
+ } else {
78
+ ensureDir(path.dirname(dest));
79
+ if (fs.statSync(src).isFile()) {
80
+ fs.copyFileSync(src, dest);
81
+ }
82
+ actions.push({
83
+ id: h.id,
84
+ action: "rescued",
85
+ from: rel,
86
+ to: dest,
87
+ });
88
+ }
89
+ }
90
+ }
91
+
92
+ return { actions, skipped, warnings };
93
+ }
94
+
95
+ module.exports = { runRescue };
package/lib/scripts.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+
9
+ function scriptsDir(cfg) {
10
+ return path.join(cfg.dir, "scripts");
11
+ }
12
+
13
+ /**
14
+ * Resolve a private script by basename only (no path separators).
15
+ * Prefers exact name, then `<name>.sh`.
16
+ */
17
+ function resolveScript(cfg, name) {
18
+ const raw = String(name || "").trim();
19
+ if (!raw || !NAME_RE.test(raw) || raw.includes("..")) {
20
+ const err = new Error(
21
+ `invalid script name: ${JSON.stringify(name)} (use a single basename)`
22
+ );
23
+ err.code = "EINVAL";
24
+ throw err;
25
+ }
26
+
27
+ const dir = scriptsDir(cfg);
28
+ const exact = path.join(dir, raw);
29
+ const withSh = raw.endsWith(".sh") ? null : path.join(dir, `${raw}.sh`);
30
+
31
+ if (fs.existsSync(exact) && fs.statSync(exact).isFile()) {
32
+ return { name: raw, path: exact, dir };
33
+ }
34
+ if (withSh && fs.existsSync(withSh) && fs.statSync(withSh).isFile()) {
35
+ return { name: raw, path: withSh, dir };
36
+ }
37
+
38
+ const err = new Error(
39
+ `script not found: ${raw} (looked in ${dir}/${raw} and ${dir}/${raw}.sh)`
40
+ );
41
+ err.code = "ENOENT";
42
+ throw err;
43
+ }
44
+
45
+ /**
46
+ * Run a private script with forwarded args. Returns { name, args, path, exitCode }.
47
+ * Uses bash for .sh; otherwise executes the file directly.
48
+ */
49
+ function runScript(cfg, name, args = [], { dryRun = false } = {}) {
50
+ const resolved = resolveScript(cfg, name);
51
+ const scriptArgs = Array.isArray(args) ? args.map(String) : [];
52
+ const useBash = resolved.path.endsWith(".sh");
53
+ const argv = useBash
54
+ ? ["bash", resolved.path, ...scriptArgs]
55
+ : [resolved.path, ...scriptArgs];
56
+
57
+ if (dryRun) {
58
+ return {
59
+ name: resolved.name,
60
+ args: scriptArgs,
61
+ path: resolved.path,
62
+ exitCode: 0,
63
+ dryRun: true,
64
+ command: argv,
65
+ };
66
+ }
67
+
68
+ const result = spawnSync(argv[0], argv.slice(1), {
69
+ stdio: "inherit",
70
+ env: process.env,
71
+ });
72
+
73
+ if (result.error) {
74
+ const err = new Error(result.error.message);
75
+ err.code = result.error.code || "EEXEC";
76
+ throw err;
77
+ }
78
+
79
+ return {
80
+ name: resolved.name,
81
+ args: scriptArgs,
82
+ path: resolved.path,
83
+ exitCode: result.status === null ? 1 : result.status,
84
+ };
85
+ }
86
+
87
+ function runScripts(cfg, names, args = [], { dryRun = false } = {}) {
88
+ const results = [];
89
+ for (const name of names) {
90
+ const result = runScript(cfg, name, args, { dryRun });
91
+ results.push(result);
92
+ if (!dryRun && result.exitCode !== 0) {
93
+ break;
94
+ }
95
+ }
96
+ return results;
97
+ }
98
+
99
+ module.exports = {
100
+ scriptsDir,
101
+ resolveScript,
102
+ runScript,
103
+ runScripts,
104
+ };
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+ const { ensureDir } = require("./link");
7
+
8
+ const SKELETON_VERSION = 1;
9
+
10
+ function packageRoot() {
11
+ return path.join(__dirname, "..");
12
+ }
13
+
14
+ function skeletonRoot() {
15
+ return path.join(packageRoot(), "skeleton");
16
+ }
17
+
18
+ function isEmptyDir(dir) {
19
+ if (!fs.existsSync(dir)) return true;
20
+ const ents = fs.readdirSync(dir).filter((n) => n !== "." && n !== "..");
21
+ return ents.length === 0;
22
+ }
23
+
24
+ function copySkeletonFile(src, dest, { force = false } = {}) {
25
+ if (fs.existsSync(dest) && !force) {
26
+ return { action: "keep", path: dest };
27
+ }
28
+ ensureDir(path.dirname(dest));
29
+ fs.cpSync(src, dest, { recursive: true, force: true });
30
+ return { action: force ? "replaced" : "added", path: dest };
31
+ }
32
+
33
+ function walkSkeleton(dir, base = dir, out = []) {
34
+ if (!fs.existsSync(dir)) return out;
35
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ const full = path.join(dir, ent.name);
37
+ const rel = path.relative(base, full);
38
+ if (ent.isDirectory()) walkSkeleton(full, base, out);
39
+ else out.push({ rel, full });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Seed missing skeleton files into cfg.dir (never overwrite unless force).
46
+ */
47
+ function seedSkeleton(cfg, { force = false, dryRun = false } = {}) {
48
+ const srcRoot = skeletonRoot();
49
+ if (!fs.existsSync(srcRoot)) {
50
+ const err = new Error(`skeleton missing in package: ${srcRoot}`);
51
+ err.code = "ENOENT";
52
+ throw err;
53
+ }
54
+ const actions = [];
55
+ for (const f of walkSkeleton(srcRoot)) {
56
+ const dest = path.join(cfg.dir, f.rel);
57
+ if (dryRun) {
58
+ actions.push({
59
+ action: fs.existsSync(dest) && !force ? "would_keep" : "would_add",
60
+ path: dest,
61
+ });
62
+ continue;
63
+ }
64
+ actions.push(copySkeletonFile(f.full, dest, { force }));
65
+ }
66
+ // ensure agent dir structure
67
+ for (const id of ["cursor", "claude", "agents", "gemini", "opencode"]) {
68
+ const r = path.join(cfg.dir, "agents", id, "rules");
69
+ const s = path.join(cfg.dir, "agents", id, "skills");
70
+ if (!dryRun) {
71
+ ensureDir(r);
72
+ ensureDir(s);
73
+ }
74
+ }
75
+ const verPath = path.join(cfg.dir, ".ai-md-skeleton-version");
76
+ if (!dryRun) {
77
+ fs.writeFileSync(verPath, String(SKELETON_VERSION) + "\n");
78
+ }
79
+ actions.push({
80
+ action: dryRun ? "would_write_version" : "version",
81
+ path: verPath,
82
+ version: SKELETON_VERSION,
83
+ });
84
+ return { actions, skeletonVersion: SKELETON_VERSION };
85
+ }
86
+
87
+ function initRepo(cfg, { noGit = false, force = false, dryRun = false } = {}) {
88
+ const dir = cfg.dir;
89
+ if (fs.existsSync(dir) && !isEmptyDir(dir) && !force) {
90
+ const err = new Error(
91
+ `${dir} is not empty. Use ai-md seed-skeleton to add missing files, or ai-md init --force`
92
+ );
93
+ err.code = "EEXIST";
94
+ throw err;
95
+ }
96
+ if (dryRun) {
97
+ return { action: "would_init", dir };
98
+ }
99
+ ensureDir(dir);
100
+ if (force && fs.existsSync(dir)) {
101
+ // only remove if force — dangerous; only clear if user asked
102
+ // We do not wipe; seed with force overwrite of skeleton files only
103
+ }
104
+ const seeded = seedSkeleton(cfg, { force: true, dryRun: false });
105
+ if (!noGit && !fs.existsSync(path.join(dir, ".git"))) {
106
+ execFileSync("git", ["init"], { cwd: dir, stdio: "pipe" });
107
+ }
108
+ return { action: "initialized", dir, ...seeded };
109
+ }
110
+
111
+ module.exports = {
112
+ SKELETON_VERSION,
113
+ skeletonRoot,
114
+ seedSkeleton,
115
+ initRepo,
116
+ isEmptyDir,
117
+ };