@dujavi/ai-md 0.5.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/README.md +47 -107
- package/bin/ai-md.js +344 -186
- package/lib/build.js +268 -0
- package/lib/commands.js +292 -99
- package/lib/config-paths.js +73 -0
- package/lib/config.js +85 -57
- package/lib/harnesses/index.js +309 -0
- package/lib/link.js +224 -0
- package/lib/rescue.js +95 -0
- package/lib/skeleton.js +117 -0
- package/lib/status.js +110 -35
- package/package.json +4 -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/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
|
+
};
|
package/lib/status.js
CHANGED
|
@@ -12,9 +12,16 @@ const {
|
|
|
12
12
|
countSkills,
|
|
13
13
|
listSkillNames,
|
|
14
14
|
listRuleNames,
|
|
15
|
-
symlinkState,
|
|
16
|
-
agentSkillTargets,
|
|
17
15
|
} = require("./config");
|
|
16
|
+
const { linkState } = require("./link");
|
|
17
|
+
const { distRoot, distDirty, sharedRoots } = require("./build");
|
|
18
|
+
const {
|
|
19
|
+
selectHarnesses,
|
|
20
|
+
resolveHarness,
|
|
21
|
+
isHarnessInstalled,
|
|
22
|
+
} = require("./harnesses");
|
|
23
|
+
const { isWsl } = require("./config-paths");
|
|
24
|
+
const { SKELETON_VERSION } = require("./skeleton");
|
|
18
25
|
|
|
19
26
|
function git(repo, args) {
|
|
20
27
|
try {
|
|
@@ -60,9 +67,7 @@ function collectProjects(cfg, { full = false, from = "base" } = {}) {
|
|
|
60
67
|
const tmplDir = templatePath(cfg, from);
|
|
61
68
|
if (!fs.existsSync(cfg.projectsDir)) return projects;
|
|
62
69
|
for (const name of fs.readdirSync(cfg.projectsDir).sort()) {
|
|
63
|
-
if (name.startsWith(".")) continue;
|
|
64
|
-
// legacy leftover
|
|
65
|
-
if (name === "template") continue;
|
|
70
|
+
if (name.startsWith(".") || name === "template") continue;
|
|
66
71
|
const projectDir = path.join(cfg.projectsDir, name);
|
|
67
72
|
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
68
73
|
const drift = templateDrift(projectDir, tmplDir);
|
|
@@ -83,25 +88,50 @@ function collectProjects(cfg, { full = false, from = "base" } = {}) {
|
|
|
83
88
|
function collectStatus(opts = {}) {
|
|
84
89
|
const cfg = resolveConfig();
|
|
85
90
|
const full = Boolean(opts.full);
|
|
86
|
-
const agents = opts.agents || ["cursor"];
|
|
91
|
+
const agents = opts.agents || cfg.agents || ["cursor"];
|
|
87
92
|
const from = opts.from || "base";
|
|
88
93
|
|
|
89
94
|
const migration = migrateLegacyTemplate(cfg);
|
|
90
|
-
|
|
91
95
|
const exists = fs.existsSync(cfg.dir);
|
|
92
96
|
const isGit = exists && fs.existsSync(path.join(cfg.dir, ".git"));
|
|
97
|
+
const shared = sharedRoots(cfg);
|
|
93
98
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const { selected, skipped, warnings } = selectHarnesses(cfg, agents, {
|
|
100
|
+
forceLink: false,
|
|
101
|
+
includeUninstalled: true,
|
|
102
|
+
});
|
|
98
103
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
const links = [];
|
|
105
|
+
const harnesses = [];
|
|
106
|
+
for (const h of selected) {
|
|
107
|
+
const installed = isHarnessInstalled(h);
|
|
108
|
+
const dist = distRoot(cfg, h.distId);
|
|
109
|
+
const dirty = distDirty(dist);
|
|
110
|
+
const entry = {
|
|
111
|
+
id: h.id,
|
|
112
|
+
distId: h.distId,
|
|
113
|
+
installed,
|
|
114
|
+
aliasOf: h.aliasOf,
|
|
115
|
+
format: h.format,
|
|
116
|
+
distDirty: dirty.dirty,
|
|
117
|
+
...(full && dirty.dirty ? { dirtyFiles: dirty.files } : {}),
|
|
118
|
+
};
|
|
119
|
+
if (installed) {
|
|
120
|
+
if (h.skills) {
|
|
121
|
+
const st = linkState(h.skills, path.join(dist, "skills"));
|
|
122
|
+
links.push({ harness: h.id, kind: "skills", ...st });
|
|
123
|
+
entry.skillsLink = st.state;
|
|
124
|
+
}
|
|
125
|
+
if (h.rules && h.format !== "skills-only") {
|
|
126
|
+
const st = linkState(h.rules, path.join(dist, "rules"));
|
|
127
|
+
links.push({ harness: h.id, kind: "rules", ...st });
|
|
128
|
+
entry.rulesLink = st.state;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
entry.skillsLink = "skipped_not_installed";
|
|
132
|
+
}
|
|
133
|
+
harnesses.push(entry);
|
|
134
|
+
}
|
|
105
135
|
|
|
106
136
|
const templates = collectTemplates(cfg, { full });
|
|
107
137
|
const projects = collectProjects(cfg, { full, from });
|
|
@@ -124,44 +154,67 @@ function collectStatus(opts = {}) {
|
|
|
124
154
|
const problems = [];
|
|
125
155
|
if (!exists) problems.push("ai_md_missing");
|
|
126
156
|
else if (!isGit) problems.push("ai_md_not_git");
|
|
157
|
+
if (fs.existsSync(path.join(cfg.dir, "rules")) || fs.existsSync(path.join(cfg.dir, "skills"))) {
|
|
158
|
+
problems.push("legacy_flat_layout");
|
|
159
|
+
}
|
|
127
160
|
for (const l of links) {
|
|
128
|
-
if (l.state !== "ok"
|
|
161
|
+
if (l.state !== "ok" && l.state !== "directory") {
|
|
162
|
+
problems.push(`${l.harness}_${l.kind}_${l.state}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const h of harnesses) {
|
|
166
|
+
if (h.installed && h.distDirty) problems.push(`dist_dirty_${h.distId}`);
|
|
129
167
|
}
|
|
130
168
|
if (!fs.existsSync(templatePath(cfg, "base")) && templates.length === 0) {
|
|
131
169
|
problems.push("templates_missing");
|
|
132
170
|
}
|
|
133
171
|
|
|
172
|
+
let skeletonVersion = null;
|
|
173
|
+
try {
|
|
174
|
+
skeletonVersion = Number(
|
|
175
|
+
fs.readFileSync(path.join(cfg.dir, ".ai-md-skeleton-version"), "utf8").trim()
|
|
176
|
+
);
|
|
177
|
+
} catch {
|
|
178
|
+
/* none */
|
|
179
|
+
}
|
|
180
|
+
if (skeletonVersion != null && skeletonVersion < SKELETON_VERSION) {
|
|
181
|
+
problems.push("skeleton_outdated");
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
return {
|
|
135
185
|
generatedAt: new Date().toISOString(),
|
|
136
186
|
dir: cfg.dir,
|
|
137
187
|
remote: remote || cfg.remote,
|
|
138
188
|
sources: cfg.sources,
|
|
189
|
+
linkMode: cfg.linkMode,
|
|
190
|
+
wsl: isWsl(),
|
|
139
191
|
machineConfigPath: cfg.machineConfigPath,
|
|
140
192
|
machineConfig: cfg.machineConfig,
|
|
141
193
|
branch,
|
|
142
194
|
dirty,
|
|
143
195
|
statusLine: aheadBehind,
|
|
144
196
|
layout: {
|
|
145
|
-
|
|
197
|
+
shared: { rules: shared.rules, skills: shared.skills },
|
|
198
|
+
agents: cfg.agentsDir,
|
|
199
|
+
dist: cfg.distDir,
|
|
146
200
|
templates: cfg.templatesDir,
|
|
147
201
|
projects: cfg.projectsDir,
|
|
148
202
|
},
|
|
149
203
|
migration,
|
|
150
204
|
counts: {
|
|
151
|
-
rules: countRules(
|
|
152
|
-
skills: countSkills(
|
|
205
|
+
rules: countRules(shared.rules),
|
|
206
|
+
skills: countSkills(shared.skills),
|
|
153
207
|
templates: templates.length,
|
|
154
208
|
projects: projects.length,
|
|
155
209
|
drifting: drifting.length,
|
|
156
210
|
problems: problems.length,
|
|
157
211
|
},
|
|
212
|
+
harnesses,
|
|
213
|
+
skipped,
|
|
214
|
+
warnings,
|
|
158
215
|
links: links.map((l) => ({
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
...(full ? { target: l.target, expected: l.expected } : {}),
|
|
162
|
-
})),
|
|
163
|
-
agentLinks: agentLinks.map((l) => ({
|
|
164
|
-
agent: l.agent,
|
|
216
|
+
harness: l.harness,
|
|
217
|
+
kind: l.kind,
|
|
165
218
|
path: l.path,
|
|
166
219
|
state: l.state,
|
|
167
220
|
...(full ? { target: l.target, expected: l.expected } : {}),
|
|
@@ -169,6 +222,8 @@ function collectStatus(opts = {}) {
|
|
|
169
222
|
templates,
|
|
170
223
|
projects,
|
|
171
224
|
driftFrom: from,
|
|
225
|
+
skeletonVersion,
|
|
226
|
+
packageSkeletonVersion: SKELETON_VERSION,
|
|
172
227
|
problems,
|
|
173
228
|
};
|
|
174
229
|
}
|
|
@@ -177,7 +232,12 @@ function statusHelp(data) {
|
|
|
177
232
|
const help = [];
|
|
178
233
|
if (data.problems.includes("ai_md_missing") || data.problems.includes("ai_md_not_git")) {
|
|
179
234
|
help.push(
|
|
180
|
-
"Run `ai-md
|
|
235
|
+
"Run `ai-md init` (no remote) or `ai-md setup --remote <git-url>`"
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (data.problems.includes("legacy_flat_layout")) {
|
|
239
|
+
help.push(
|
|
240
|
+
"Migrate flat rules/skills into shared/ (and agents/<id>/); see package README"
|
|
181
241
|
);
|
|
182
242
|
}
|
|
183
243
|
if (!data.machineConfig) {
|
|
@@ -185,23 +245,38 @@ function statusHelp(data) {
|
|
|
185
245
|
"Persist remote/dir with `ai-md setup --remote <url>` or `ai-md config set --remote <url> --dir ~/.ai-md`"
|
|
186
246
|
);
|
|
187
247
|
}
|
|
188
|
-
if (data.links.some((l) => l.state !== "ok")) {
|
|
189
|
-
help.push("Run `ai-md doctor --fix` to
|
|
248
|
+
if (data.links.some((l) => l.state !== "ok" && l.state !== "directory")) {
|
|
249
|
+
help.push("Run `ai-md doctor --fix` to rebuild dist and repair links");
|
|
250
|
+
}
|
|
251
|
+
if (data.problems.some((p) => p.startsWith("dist_dirty_"))) {
|
|
252
|
+
help.push("Run `ai-md rescue --agents <id>` or `ai-md build --force`");
|
|
253
|
+
}
|
|
254
|
+
if (data.problems.includes("skeleton_outdated")) {
|
|
255
|
+
help.push("Run `ai-md seed-skeleton` to add new recommended files");
|
|
256
|
+
}
|
|
257
|
+
if (data.wsl) {
|
|
258
|
+
help.push(
|
|
259
|
+
"WSL detected: use Windows ai-md for Windows Cursor (homes differ)"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (data.skipped && data.skipped.some((s) => s.reason === "not_installed")) {
|
|
263
|
+
help.push(
|
|
264
|
+
"Some harnesses skipped (AI not installed). Install the tool or use --force-link"
|
|
265
|
+
);
|
|
190
266
|
}
|
|
191
267
|
if (data.problems.includes("templates_missing")) {
|
|
192
|
-
help.push("
|
|
268
|
+
help.push("Run `ai-md seed-skeleton` or add templates/base");
|
|
193
269
|
}
|
|
194
270
|
if (data.counts.drifting > 0) {
|
|
195
271
|
help.push(
|
|
196
|
-
`Run \`ai-md apply-template --project <name> --from ${data.driftFrom || "base"}
|
|
272
|
+
`Run \`ai-md apply-template --project <name> --from ${data.driftFrom || "base"}\``
|
|
197
273
|
);
|
|
198
274
|
}
|
|
199
275
|
if (data.dirty) {
|
|
200
|
-
help.push('Run `ai-md push -m "<why>"` to commit and push
|
|
276
|
+
help.push('Run `ai-md push -m "<why>"` to commit and push');
|
|
201
277
|
} else {
|
|
202
|
-
help.push("Edit
|
|
278
|
+
help.push("Edit shared/ or agents/<id>/ only; then `ai-md build`");
|
|
203
279
|
}
|
|
204
|
-
help.push("Run `ai-md init-project --repo <path> --from base` to seed a project");
|
|
205
280
|
help.push("Run `ai-md status --json` for machine-readable output");
|
|
206
281
|
return help;
|
|
207
282
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dujavi/ai-md",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AXI-shaped CLI for
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "AXI-shaped CLI for private ~/.ai-md: shared+agents build to dist, multi-harness link (Cursor, Claude, agents, …)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai-md": "bin/ai-md.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"bin/",
|
|
10
10
|
"lib/",
|
|
11
11
|
"scripts/",
|
|
12
|
+
"skeleton/",
|
|
12
13
|
"docs/",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE"
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"node": ">=18"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
|
-
"test": "node bin/ai-md.js --help && node bin/ai-md.js status --json >/dev/null"
|
|
21
|
+
"test": "node bin/ai-md.js --help && node bin/ai-md.js harness list --json >/dev/null && node bin/ai-md.js status --json >/dev/null"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@toon-format/toon": "^2.3.1"
|