@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/commands.js CHANGED
@@ -5,60 +5,24 @@ const path = require("path");
5
5
  const {
6
6
  resolveConfig,
7
7
  templatePath,
8
- symlinkState,
9
- agentSkillTargets,
8
+ writeMachineConfig,
9
+ gitClone,
10
+ gitPull,
11
+ gitPush,
10
12
  } = require("./config");
11
13
  const { collectStatus, statusHelp, collectProjects } = require("./status");
12
-
13
- function ensureDir(p) {
14
- fs.mkdirSync(p, { recursive: true });
15
- }
16
-
17
- function linkPath(target, link, { force = false, dryRun = false } = {}) {
18
- ensureDir(path.dirname(link));
19
- ensureDir(target);
20
- const state = symlinkState(link, target);
21
- if (state.state === "ok") {
22
- return { path: link, action: "ok", target };
23
- }
24
- if (state.state === "not_symlink" && !force) {
25
- const err = new Error(
26
- `${link} exists and is not a symlink (use --force to replace)`
27
- );
28
- err.code = "EEXIST";
29
- throw err;
30
- }
31
- if (dryRun) {
32
- return { path: link, action: state.state === "missing" ? "would_link" : "would_repair", target };
33
- }
34
- if (fs.existsSync(link) || state.state !== "missing") {
35
- try {
36
- fs.lstatSync(link);
37
- fs.rmSync(link, { recursive: true, force: true });
38
- } catch {
39
- /* missing */
40
- }
41
- }
42
- fs.symlinkSync(target, link);
43
- return {
44
- path: link,
45
- action: state.state === "missing" ? "linked" : "repaired",
46
- target,
47
- };
48
- }
49
-
50
- function ensureCursorLinks(cfg, opts = {}) {
51
- return [
52
- linkPath(cfg.skillsDir, cfg.cursorSkills, opts),
53
- linkPath(cfg.rulesDir, cfg.cursorRules, opts),
54
- ];
55
- }
56
-
57
- function ensureAgentSkillLinks(cfg, agents, opts = {}) {
58
- return agentSkillTargets(cfg.home, agents)
59
- .filter((t) => t.name !== "cursor") // cursor handled via ensureCursorLinks
60
- .map((t) => linkPath(cfg.skillsDir, t.path, opts));
61
- }
14
+ const { linkPath, ensureDir, wslWarnings, defaultLinkMode } = require("./link");
15
+ const { runBuild, distRoot, distDirty } = require("./build");
16
+ const { runRescue } = require("./rescue");
17
+ const {
18
+ selectHarnesses,
19
+ resolveHarness,
20
+ listBuiltinIds,
21
+ getBuiltin,
22
+ ensureAgentSourceDirs,
23
+ isHarnessInstalled,
24
+ } = require("./harnesses");
25
+ const { seedSkeleton, initRepo, SKELETON_VERSION } = require("./skeleton");
62
26
 
63
27
  function ensureGitignore(repoPath, { dryRun = false } = {}) {
64
28
  const gitignore = path.join(repoPath, ".gitignore");
@@ -84,6 +48,37 @@ function copyDir(src, dest, { dryRun = false } = {}) {
84
48
  return { action: "copied", from: src, to: dest };
85
49
  }
86
50
 
51
+ function countEntries(dir) {
52
+ if (!fs.existsSync(dir)) return 0;
53
+ return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length;
54
+ }
55
+
56
+ function mergeTemplate(templateDir, projectDir, { dryRun = false } = {}) {
57
+ const actions = [];
58
+ for (const kind of ["rules", "skills"]) {
59
+ const srcRoot = path.join(templateDir, kind);
60
+ const destRoot = path.join(projectDir, kind);
61
+ if (!fs.existsSync(srcRoot)) continue;
62
+ ensureDir(destRoot);
63
+ for (const name of fs.readdirSync(srcRoot)) {
64
+ if (name.startsWith(".")) continue;
65
+ const src = path.join(srcRoot, name);
66
+ const dest = path.join(destRoot, name);
67
+ if (fs.existsSync(dest)) {
68
+ actions.push({ action: "keep", path: dest });
69
+ continue;
70
+ }
71
+ if (dryRun) {
72
+ actions.push({ action: "would_add", path: dest, from: src });
73
+ } else {
74
+ fs.cpSync(src, dest, { recursive: true });
75
+ actions.push({ action: "added", path: dest, from: src });
76
+ }
77
+ }
78
+ }
79
+ return actions;
80
+ }
81
+
87
82
  function initProject({
88
83
  repo,
89
84
  name,
@@ -92,8 +87,8 @@ function initProject({
92
87
  dryRun = false,
93
88
  } = {}) {
94
89
  const cfg = resolveConfig();
95
- if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
96
- const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
90
+ if (!fs.existsSync(path.join(cfg.dir, ".git")) && !fs.existsSync(cfg.dir)) {
91
+ const err = new Error(`${cfg.dir} missing; run ai-md init or ai-md install first`);
97
92
  err.code = "ENOENT";
98
93
  throw err;
99
94
  }
@@ -131,7 +126,13 @@ function initProject({
131
126
  }
132
127
 
133
128
  const link = path.join(absRepo, ".cursor");
134
- actions.push(linkPath(projectDir, link, { force, dryRun }));
129
+ actions.push(
130
+ linkPath(projectDir, link, {
131
+ force,
132
+ dryRun,
133
+ linkMode: cfg.linkMode,
134
+ })
135
+ );
135
136
  actions.push(ensureGitignore(absRepo, { dryRun }));
136
137
 
137
138
  return {
@@ -144,37 +145,6 @@ function initProject({
144
145
  };
145
146
  }
146
147
 
147
- function countEntries(dir) {
148
- if (!fs.existsSync(dir)) return 0;
149
- return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length;
150
- }
151
-
152
- function mergeTemplate(templateDir, projectDir, { dryRun = false } = {}) {
153
- const actions = [];
154
- for (const kind of ["rules", "skills"]) {
155
- const srcRoot = path.join(templateDir, kind);
156
- const destRoot = path.join(projectDir, kind);
157
- if (!fs.existsSync(srcRoot)) continue;
158
- ensureDir(destRoot);
159
- for (const name of fs.readdirSync(srcRoot)) {
160
- if (name.startsWith(".")) continue;
161
- const src = path.join(srcRoot, name);
162
- const dest = path.join(destRoot, name);
163
- if (fs.existsSync(dest)) {
164
- actions.push({ action: "keep", path: dest });
165
- continue;
166
- }
167
- if (dryRun) {
168
- actions.push({ action: "would_add", path: dest, from: src });
169
- } else {
170
- fs.cpSync(src, dest, { recursive: true });
171
- actions.push({ action: "added", path: dest, from: src });
172
- }
173
- }
174
- }
175
- return actions;
176
- }
177
-
178
148
  function applyTemplate({ project, from = "base", dryRun = false } = {}) {
179
149
  const cfg = resolveConfig();
180
150
  if (!project) {
@@ -200,11 +170,6 @@ function applyTemplate({ project, from = "base", dryRun = false } = {}) {
200
170
 
201
171
  function linkProject({ repo, name, force = false, dryRun = false } = {}) {
202
172
  const cfg = resolveConfig();
203
- if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
204
- const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
205
- err.code = "ENOENT";
206
- throw err;
207
- }
208
173
  if (!repo || !fs.existsSync(repo)) {
209
174
  const err = new Error("missing or invalid --repo <path>");
210
175
  err.code = "EINVAL";
@@ -218,37 +183,248 @@ function linkProject({ repo, name, force = false, dryRun = false } = {}) {
218
183
  ensureDir(path.join(projectDir, "skills"));
219
184
  }
220
185
  const actions = [
221
- linkPath(projectDir, path.join(absRepo, ".cursor"), { force, dryRun }),
186
+ linkPath(projectDir, path.join(absRepo, ".cursor"), {
187
+ force,
188
+ dryRun,
189
+ linkMode: cfg.linkMode,
190
+ }),
222
191
  ensureGitignore(absRepo, { dryRun }),
223
192
  ];
224
193
  return { project: projectName, projectDir, repo: absRepo, actions };
225
194
  }
226
195
 
227
- function runDoctor({ fix = false, force = false, agents = ["cursor"], dryRun = false } = {}) {
196
+ /** Link dist/<id> live harness paths. Only for installed harnesses. */
197
+ function linkHarnesses(cfg, agents, opts = {}) {
198
+ const {
199
+ force = false,
200
+ dryRun = false,
201
+ forceLink = false,
202
+ } = opts;
203
+ const { selected, skipped, warnings } = selectHarnesses(cfg, agents, {
204
+ forceLink,
205
+ });
206
+ const links = [];
207
+ const platformWarn = wslWarnings(cfg, selected);
208
+
209
+ const linkedDist = new Set();
210
+ for (const h of selected) {
211
+ if (linkedDist.has(h.distId)) continue;
212
+ linkedDist.add(h.distId);
213
+ const dist = distRoot(cfg, h.distId);
214
+ if (!fs.existsSync(dist)) {
215
+ links.push({
216
+ harness: h.id,
217
+ action: "skip",
218
+ reason: "dist_missing",
219
+ help: "Run ai-md build first",
220
+ });
221
+ continue;
222
+ }
223
+ if (h.skills) {
224
+ links.push({
225
+ harness: h.id,
226
+ kind: "skills",
227
+ ...linkPath(path.join(dist, "skills"), h.skills, {
228
+ force,
229
+ dryRun,
230
+ linkMode: cfg.linkMode,
231
+ }),
232
+ });
233
+ }
234
+ if (h.rules && h.format !== "skills-only") {
235
+ links.push({
236
+ harness: h.id,
237
+ kind: "rules",
238
+ ...linkPath(path.join(dist, "rules"), h.rules, {
239
+ force,
240
+ dryRun,
241
+ linkMode: cfg.linkMode,
242
+ }),
243
+ });
244
+ }
245
+ }
246
+
247
+ return {
248
+ links,
249
+ skipped,
250
+ warnings: [...warnings, ...platformWarn],
251
+ };
252
+ }
253
+
254
+ function buildAndLink(cfg, opts = {}) {
255
+ const build = runBuild(cfg, opts);
256
+ const link = linkHarnesses(cfg, opts.agents, opts);
257
+ return { build, link };
258
+ }
259
+
260
+ function runInstall(cfg, opts = {}) {
261
+ const dir = cfg.dir;
262
+ if (!fs.existsSync(dir)) {
263
+ gitClone(cfg.remote, dir, { dryRun: opts.dryRun });
264
+ } else if (!fs.existsSync(path.join(dir, ".git"))) {
265
+ const err = new Error(
266
+ `${dir} exists but is not a git repo. Use ai-md init or remove it.`
267
+ );
268
+ err.code = "EEXIST";
269
+ throw err;
270
+ }
271
+ // migrate hint: if legacy flat rules/ exist, status will note
272
+ return buildAndLink(cfg, {
273
+ agents: opts.agents,
274
+ force: opts.force,
275
+ dryRun: opts.dryRun,
276
+ forceLink: opts.forceLink,
277
+ });
278
+ }
279
+
280
+ function runPull(cfg, opts = {}) {
281
+ if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
282
+ const err = new Error(`${cfg.dir} is not a git repo; run ai-md install`);
283
+ err.code = "ENOENT";
284
+ throw err;
285
+ }
286
+ gitPull(cfg.dir, { dryRun: opts.dryRun });
287
+ return buildAndLink(cfg, {
288
+ agents: opts.agents,
289
+ force: opts.force,
290
+ dryRun: opts.dryRun,
291
+ forceLink: opts.forceLink,
292
+ });
293
+ }
294
+
295
+ function runPush(cfg, opts = {}) {
296
+ return gitPush(cfg.dir, {
297
+ message: opts.message,
298
+ dryRun: opts.dryRun,
299
+ });
300
+ }
301
+
302
+ function runDoctor({
303
+ fix = false,
304
+ force = false,
305
+ agents = ["cursor"],
306
+ dryRun = false,
307
+ forceLink = false,
308
+ } = {}) {
228
309
  const cfg = resolveConfig();
229
310
  const status = collectStatus({ full: true, agents });
230
311
  const repairs = [];
231
312
  if (fix || force) {
232
- repairs.push(...ensureCursorLinks(cfg, { force: true, dryRun }));
233
- repairs.push(...ensureAgentSkillLinks(cfg, agents, { force: true, dryRun }));
313
+ try {
314
+ const bl = buildAndLink(cfg, {
315
+ agents,
316
+ force: true,
317
+ dryRun,
318
+ forceLink,
319
+ });
320
+ repairs.push(bl);
321
+ } catch (e) {
322
+ repairs.push({ error: e.message, code: e.code, dirty: e.dirty });
323
+ }
234
324
  }
235
325
  const after = fix || force ? collectStatus({ full: true, agents }) : status;
236
326
  return {
237
- before: {
238
- problems: status.problems,
239
- counts: status.counts,
240
- },
327
+ before: { problems: status.problems, counts: status.counts },
241
328
  repairs,
242
329
  after: {
243
330
  problems: after.problems,
244
331
  counts: after.counts,
245
332
  links: after.links,
333
+ harnesses: after.harnesses,
246
334
  projects: after.projects,
247
335
  },
248
336
  help: statusHelp(after),
337
+ wsl: wslWarnings(cfg, []),
338
+ };
339
+ }
340
+
341
+ function harnessList(cfg) {
342
+ const ids = new Set([
343
+ ...listBuiltinIds(),
344
+ ...Object.keys((cfg.machineConfig && cfg.machineConfig.harnesses) || {}),
345
+ ]);
346
+ return [...ids].sort().map((id) => {
347
+ const def = resolveHarness(id, cfg);
348
+ return {
349
+ id: def.id,
350
+ format: def.format,
351
+ emitter: def.emitter,
352
+ aliasOf: def.aliasOf,
353
+ skills: def.skills,
354
+ rules: def.rules,
355
+ enabled: def.enabled,
356
+ installed: isHarnessInstalled(def),
357
+ distId: def.distId,
358
+ tier: def.tier,
359
+ };
360
+ });
361
+ }
362
+
363
+ function harnessShow(cfg, id) {
364
+ const def = resolveHarness(id, cfg);
365
+ return {
366
+ ...def,
367
+ installed: isHarnessInstalled(def),
368
+ builtin: Boolean(getBuiltin(id)),
249
369
  };
250
370
  }
251
371
 
372
+ function harnessSet(cfg, id, { skills, rules, format } = {}) {
373
+ const key = String(id).toLowerCase();
374
+ const existing = { ...(cfg.machineConfig || {}) };
375
+ const harnesses = { ...(existing.harnesses || {}) };
376
+ const cur = { ...(harnesses[key] || {}) };
377
+ if (skills != null) cur.skills = skills;
378
+ if (rules != null) cur.rules = rules;
379
+ if (format != null) cur.format = format;
380
+ cur.enabled = cur.enabled !== false;
381
+ harnesses[key] = cur;
382
+ const agents = existing.agents || ["cursor"];
383
+ if (!agents.includes(key) && key !== "codex") {
384
+ agents.push(key);
385
+ }
386
+ const saved = writeMachineConfig({ ...existing, harnesses, agents });
387
+ ensureAgentSourceDirs(
388
+ { ...cfg, dir: saved.config.dir || cfg.dir },
389
+ key === "codex" ? "agents" : key
390
+ );
391
+ return { id: key, harness: cur, config: saved };
392
+ }
393
+
394
+ function harnessUnset(cfg, id, { pathsOnly = false } = {}) {
395
+ const key = String(id).toLowerCase();
396
+ const existing = { ...(cfg.machineConfig || {}) };
397
+ const harnesses = { ...(existing.harnesses || {}) };
398
+ if (pathsOnly && harnesses[key]) {
399
+ delete harnesses[key].skills;
400
+ delete harnesses[key].rules;
401
+ delete harnesses[key].format;
402
+ } else {
403
+ delete harnesses[key];
404
+ }
405
+ return writeMachineConfig({ ...existing, harnesses });
406
+ }
407
+
408
+ function harnessEnable(cfg, id, enabled) {
409
+ const key = String(id).toLowerCase();
410
+ const existing = { ...(cfg.machineConfig || {}) };
411
+ const harnesses = { ...(existing.harnesses || {}) };
412
+ harnesses[key] = { ...(harnesses[key] || {}), enabled };
413
+ let agents = [...(existing.agents || ["cursor"])];
414
+ if (enabled && !agents.includes(key) && key !== "codex") agents.push(key);
415
+ if (!enabled) agents = agents.filter((a) => a !== key);
416
+ return writeMachineConfig({ ...existing, harnesses, agents });
417
+ }
418
+
419
+ // legacy exports expected by older callers
420
+ function ensureCursorLinks(cfg, opts = {}) {
421
+ return linkHarnesses(cfg, ["cursor"], opts).links;
422
+ }
423
+
424
+ function ensureAgentSkillLinks(cfg, agents, opts = {}) {
425
+ return linkHarnesses(cfg, agents, opts).links;
426
+ }
427
+
252
428
  module.exports = {
253
429
  ensureCursorLinks,
254
430
  ensureAgentSkillLinks,
@@ -258,5 +434,22 @@ module.exports = {
258
434
  linkProject,
259
435
  runDoctor,
260
436
  linkPath,
437
+ linkHarnesses,
438
+ buildAndLink,
439
+ runInstall,
440
+ runPull,
441
+ runPush,
442
+ runBuild,
443
+ runRescue,
444
+ seedSkeleton,
445
+ initRepo,
446
+ harnessList,
447
+ harnessShow,
448
+ harnessSet,
449
+ harnessUnset,
450
+ harnessEnable,
261
451
  collectProjects,
452
+ SKELETON_VERSION,
453
+ distDirty,
454
+ defaultLinkMode,
262
455
  };
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ function expandHome(p) {
8
+ if (!p) return p;
9
+ let s = String(p);
10
+ if (process.platform === "win32") {
11
+ s = s.replace(/%([^%]+)%/g, (_, name) => process.env[name] || `%${name}%`);
12
+ }
13
+ if (s === "~") return os.homedir();
14
+ if (s.startsWith("~/") || s.startsWith("~\\")) {
15
+ return path.join(os.homedir(), s.slice(2));
16
+ }
17
+ return s;
18
+ }
19
+
20
+ function isWsl() {
21
+ if (process.platform !== "linux") return false;
22
+ try {
23
+ const rel = os.release().toLowerCase();
24
+ if (rel.includes("microsoft") || rel.includes("wsl")) return true;
25
+ } catch {
26
+ /* ignore */
27
+ }
28
+ try {
29
+ const v = fs.readFileSync("/proc/version", "utf8").toLowerCase();
30
+ return v.includes("microsoft") || v.includes("wsl");
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function defaultLinkMode() {
37
+ if (process.platform === "win32") return "junction";
38
+ return "symlink";
39
+ }
40
+
41
+ function looksLikeWindowsPath(p) {
42
+ return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
43
+ }
44
+
45
+ function normalizePath(p) {
46
+ if (!p) return p;
47
+ try {
48
+ return fs.realpathSync.native
49
+ ? fs.realpathSync.native(p)
50
+ : fs.realpathSync(p);
51
+ } catch {
52
+ return path.resolve(p);
53
+ }
54
+ }
55
+
56
+ function pathsEqual(a, b) {
57
+ if (!a || !b) return false;
58
+ const na = normalizePath(a);
59
+ const nb = normalizePath(b);
60
+ if (process.platform === "win32") {
61
+ return na.toLowerCase() === nb.toLowerCase();
62
+ }
63
+ return na === nb;
64
+ }
65
+
66
+ module.exports = {
67
+ expandHome,
68
+ isWsl,
69
+ defaultLinkMode,
70
+ looksLikeWindowsPath,
71
+ normalizePath,
72
+ pathsEqual,
73
+ };