@dujavi/ai-md 0.5.0 → 0.6.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 +50 -106
- package/bin/ai-md.js +358 -191
- package/lib/build.js +268 -0
- package/lib/commands.js +421 -99
- package/lib/config-paths.js +73 -0
- package/lib/config.js +124 -67
- package/lib/harnesses/index.js +309 -0
- package/lib/link.js +224 -0
- package/lib/remote.js +116 -0
- package/lib/rescue.js +95 -0
- package/lib/skeleton.js +117 -0
- package/lib/status.js +114 -36
- package/package.json +4 -3
- package/scripts/sync-config.sh +10 -3
- package/skeleton/README.md +3 -0
- package/skeleton/agents/agents/skills/.gitkeep +0 -0
- package/skeleton/agents/claude/rules/.gitkeep +0 -0
- package/skeleton/agents/claude/skills/.gitkeep +0 -0
- package/skeleton/agents/cursor/skills/.gitkeep +0 -0
- package/skeleton/agents/gemini/skills/.gitkeep +0 -0
- package/skeleton/agents/opencode/skills/.gitkeep +0 -0
- package/skeleton/projects/.gitkeep +0 -0
- package/skeleton/scripts/.gitkeep +0 -0
- package/skeleton/shared/rules/edit-source-not-dist.mdc +23 -0
- package/skeleton/shared/rules/personal-config.mdc +49 -0
- package/skeleton/shared/skills/ai-md-config/SKILL.md +51 -0
- package/skeleton/templates/base/rules/.gitkeep +0 -0
- package/skeleton/templates/base/rules/project-context.mdc +8 -0
- package/skeleton/templates/base/skills/.gitkeep +0 -0
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/remote.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
function runCapture(cmd, args, opts = {}) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync(cmd, args, {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
timeout: opts.timeout || 20000,
|
|
11
|
+
}).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GitHub login from authenticated `gh`, else git config github.user.
|
|
19
|
+
*/
|
|
20
|
+
function detectGithubUser() {
|
|
21
|
+
const fromGh = runCapture("gh", ["api", "user", "-q", ".login"]);
|
|
22
|
+
if (fromGh && !fromGh.includes(" ") && /^[A-Za-z0-9-]+$/.test(fromGh)) {
|
|
23
|
+
return { user: fromGh, via: "gh" };
|
|
24
|
+
}
|
|
25
|
+
const fromGit = runCapture("git", ["config", "--global", "github.user"]);
|
|
26
|
+
if (fromGit && /^[A-Za-z0-9-]+$/.test(fromGit)) {
|
|
27
|
+
return { user: fromGit, via: "git" };
|
|
28
|
+
}
|
|
29
|
+
return { user: null, via: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function candidateRemotes(user) {
|
|
33
|
+
return [
|
|
34
|
+
`https://github.com/${user}/.ai-md.git`,
|
|
35
|
+
`git@github.com:${user}/.ai-md.git`,
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remoteReachable(url) {
|
|
40
|
+
const out = runCapture("git", ["ls-remote", "--exit-code", url, "HEAD"], {
|
|
41
|
+
timeout: 25000,
|
|
42
|
+
});
|
|
43
|
+
// ls-remote --exit-code returns null from runCapture on failure
|
|
44
|
+
// success returns stdout (may be empty-ish but non-null string with sha)
|
|
45
|
+
return out != null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function repoExistsViaGh(user) {
|
|
49
|
+
const url = runCapture("gh", [
|
|
50
|
+
"repo",
|
|
51
|
+
"view",
|
|
52
|
+
`${user}/.ai-md`,
|
|
53
|
+
"--json",
|
|
54
|
+
"url",
|
|
55
|
+
"-q",
|
|
56
|
+
".url",
|
|
57
|
+
]);
|
|
58
|
+
return Boolean(url);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Only invent a default remote when gh/git identifies a user AND
|
|
63
|
+
* github.com/{user}/.ai-md exists.
|
|
64
|
+
*/
|
|
65
|
+
function detectDefaultRemote() {
|
|
66
|
+
const { user, via } = detectGithubUser();
|
|
67
|
+
if (!user) {
|
|
68
|
+
return {
|
|
69
|
+
remote: null,
|
|
70
|
+
source: "none",
|
|
71
|
+
githubUser: null,
|
|
72
|
+
githubVia: null,
|
|
73
|
+
verified: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (repoExistsViaGh(user)) {
|
|
78
|
+
return {
|
|
79
|
+
remote: `https://github.com/${user}/.ai-md.git`,
|
|
80
|
+
source: "detected",
|
|
81
|
+
githubUser: user,
|
|
82
|
+
githubVia: via,
|
|
83
|
+
verified: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const url of candidateRemotes(user)) {
|
|
88
|
+
if (remoteReachable(url)) {
|
|
89
|
+
return {
|
|
90
|
+
remote: url.startsWith("git@")
|
|
91
|
+
? `https://github.com/${user}/.ai-md.git`
|
|
92
|
+
: url,
|
|
93
|
+
source: "detected",
|
|
94
|
+
githubUser: user,
|
|
95
|
+
githubVia: via,
|
|
96
|
+
verified: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
remote: null,
|
|
103
|
+
source: "none",
|
|
104
|
+
githubUser: user,
|
|
105
|
+
githubVia: via,
|
|
106
|
+
verified: false,
|
|
107
|
+
hint: `No github.com/${user}/.ai-md repo found (create it or pass --remote)`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
detectGithubUser,
|
|
113
|
+
detectDefaultRemote,
|
|
114
|
+
remoteReachable,
|
|
115
|
+
candidateRemotes,
|
|
116
|
+
};
|
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/skeleton.js
ADDED
|
@@ -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
|
+
};
|