@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/lib/config.js CHANGED
@@ -1,22 +1,17 @@
1
1
  "use strict";
2
2
 
3
3
  const fs = require("fs");
4
- const os = require("os");
5
4
  const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+ const { expandHome } = require("./config-paths");
6
7
 
7
8
  const DEFAULT_REMOTE = "https://github.com/dujavi/.ai-md.git";
8
9
 
9
- function expandHome(p) {
10
- if (!p) return p;
11
- if (p === "~") return os.homedir();
12
- if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
13
- return p;
14
- }
15
-
16
- /** Machine-local config (not inside the private content repo). */
17
10
  function machineConfigPath(env = process.env) {
18
11
  if (env.AI_MD_CONFIG) return expandHome(env.AI_MD_CONFIG);
19
- const xdg = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
12
+ const xdg =
13
+ env.XDG_CONFIG_HOME ||
14
+ path.join(require("os").homedir(), ".config");
20
15
  return path.join(xdg, "ai-md", "config.json");
21
16
  }
22
17
 
@@ -36,20 +31,16 @@ function readMachineConfig(env = process.env) {
36
31
  }
37
32
  }
38
33
 
39
- function writeMachineConfig({ dir, remote }, env = process.env, { dryRun = false } = {}) {
34
+ function writeMachineConfig(patch, env = process.env, { dryRun = false } = {}) {
40
35
  const configPath = machineConfigPath(env);
41
36
  const existing = readMachineConfig(env).raw || {};
42
37
  const next = {
43
38
  ...existing,
44
- ...(dir != null ? { dir: expandHome(dir) } : {}),
45
- ...(remote != null ? { remote: String(remote) } : {}),
39
+ ...patch,
46
40
  updatedAt: new Date().toISOString(),
47
41
  };
48
- if (!next.dir && !next.remote) {
49
- const err = new Error("nothing to write: provide --dir and/or --remote");
50
- err.code = "EINVAL";
51
- throw err;
52
- }
42
+ if (patch.dir != null) next.dir = expandHome(patch.dir);
43
+ if (patch.remote != null) next.remote = String(patch.remote);
53
44
  if (dryRun) {
54
45
  return { action: "would_write", path: configPath, config: next };
55
46
  }
@@ -65,11 +56,9 @@ function writeMachineConfig({ dir, remote }, env = process.env, { dryRun = false
65
56
  return { action: "wrote", path: configPath, config: next };
66
57
  }
67
58
 
68
- /**
69
- * Precedence: explicit opts > env > machine config file > defaults.
70
- * @param {{ dir?: string, remote?: string }} [opts]
71
- */
72
59
  function resolveConfig(env = process.env, opts = {}) {
60
+ const os = require("os");
61
+ const { defaultLinkMode } = require("./config-paths");
73
62
  const home = os.homedir();
74
63
  const stored = readMachineConfig(env);
75
64
  const defaultDir = path.join(home, ".ai-md");
@@ -77,11 +66,8 @@ function resolveConfig(env = process.env, opts = {}) {
77
66
  const envDir = env.AI_MD_DIR || env.CURSOR_MD_DIR || null;
78
67
  const envRemote = env.AI_MD_REMOTE || env.CURSOR_MD_REMOTE || null;
79
68
 
80
- const dir = expandHome(
81
- opts.dir || envDir || stored.dir || defaultDir
82
- );
83
- const remote =
84
- opts.remote || envRemote || stored.remote || DEFAULT_REMOTE;
69
+ const dir = expandHome(opts.dir || envDir || stored.dir || defaultDir);
70
+ const remote = opts.remote || envRemote || stored.remote || DEFAULT_REMOTE;
85
71
 
86
72
  const sources = {
87
73
  dir: opts.dir
@@ -100,21 +86,33 @@ function resolveConfig(env = process.env, opts = {}) {
100
86
  : "default",
101
87
  };
102
88
 
89
+ const raw = stored.raw || {};
90
+ const agents = Array.isArray(raw.agents) && raw.agents.length
91
+ ? raw.agents.map((a) => String(a).toLowerCase())
92
+ : ["cursor"];
93
+ const linkMode = opts.linkMode || raw.linkMode || defaultLinkMode();
94
+
103
95
  const templatesDir = path.join(dir, "templates");
104
96
  return {
105
97
  home,
106
98
  dir,
107
99
  remote,
108
100
  sources,
101
+ agents,
102
+ linkMode,
109
103
  machineConfigPath: stored.path,
110
- machineConfig: stored.raw,
104
+ machineConfig: raw,
111
105
  cursorSkills: path.join(home, ".cursor", "skills"),
112
106
  cursorRules: path.join(home, ".cursor", "rules"),
113
107
  projectsDir: path.join(dir, "projects"),
114
108
  templatesDir,
115
109
  templateDir: path.join(templatesDir, "base"),
116
- skillsDir: path.join(dir, "skills"),
117
- rulesDir: path.join(dir, "rules"),
110
+ // legacy aliases → shared (build uses shared/)
111
+ skillsDir: path.join(dir, "shared", "skills"),
112
+ rulesDir: path.join(dir, "shared", "rules"),
113
+ sharedDir: path.join(dir, "shared"),
114
+ agentsDir: path.join(dir, "agents"),
115
+ distDir: path.join(dir, "dist"),
118
116
  };
119
117
  }
120
118
 
@@ -142,16 +140,14 @@ function countRules(rulesDir) {
142
140
  if (!fs.existsSync(rulesDir)) return 0;
143
141
  return fs
144
142
  .readdirSync(rulesDir)
145
- .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
146
- .length;
143
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md")).length;
147
144
  }
148
145
 
149
146
  function countSkills(skillsDir) {
150
147
  if (!fs.existsSync(skillsDir)) return 0;
151
148
  return fs
152
149
  .readdirSync(skillsDir, { withFileTypes: true })
153
- .filter((d) => d.isDirectory() && !d.name.startsWith("."))
154
- .length;
150
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".")).length;
155
151
  }
156
152
 
157
153
  function listSkillNames(skillsDir) {
@@ -172,33 +168,24 @@ function listRuleNames(rulesDir) {
172
168
  }
173
169
 
174
170
  function symlinkState(linkPath, expected) {
175
- let stat;
176
- try {
177
- stat = fs.lstatSync(linkPath);
178
- } catch {
179
- return { path: linkPath, state: "missing", target: null, expected };
180
- }
181
- if (!stat.isSymbolicLink()) {
182
- return { path: linkPath, state: "not_symlink", target: null, expected };
183
- }
184
- const target = fs.readlinkSync(linkPath);
185
- if (target === expected) {
186
- return { path: linkPath, state: "ok", target, expected };
187
- }
188
- return { path: linkPath, state: "wrong_target", target, expected };
171
+ const { linkState } = require("./link");
172
+ return linkState(linkPath, expected);
189
173
  }
190
174
 
175
+ /** @deprecated use harnesses.selectHarnesses */
191
176
  function agentSkillTargets(home, agents) {
192
- const map = {
193
- cursor: path.join(home, ".cursor", "skills"),
194
- claude: path.join(home, ".claude", "skills"),
195
- agents: path.join(home, ".agents", "skills"),
196
- };
177
+ const { resolveHarness } = require("./harnesses");
178
+ const cfg = { home, machineConfig: {} };
197
179
  return (agents || [])
198
- .map((a) => String(a).trim().toLowerCase())
199
- .filter(Boolean)
200
- .filter((name) => map[name])
201
- .map((name) => ({ name, path: map[name] }));
180
+ .map((a) => {
181
+ try {
182
+ const h = resolveHarness(a, { ...cfg, home });
183
+ return h.skills ? { name: h.id, path: h.skills } : null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ })
188
+ .filter(Boolean);
202
189
  }
203
190
 
204
191
  function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
@@ -213,6 +200,44 @@ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
213
200
  return { action: "migrated", from: legacy, to: dest };
214
201
  }
215
202
 
203
+ function gitClone(remote, dir, { dryRun = false } = {}) {
204
+ if (dryRun) return { action: "would_clone", remote, dir };
205
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
206
+ execFileSync("git", ["clone", remote, dir], { stdio: "inherit" });
207
+ return { action: "cloned", remote, dir };
208
+ }
209
+
210
+ function gitPull(dir, { dryRun = false } = {}) {
211
+ if (dryRun) return { action: "would_pull", dir };
212
+ execFileSync("git", ["-C", dir, "pull", "--rebase", "--autostash"], {
213
+ stdio: "inherit",
214
+ });
215
+ return { action: "pulled", dir };
216
+ }
217
+
218
+ function gitPush(dir, { message, dryRun = false } = {}) {
219
+ if (dryRun) return { action: "would_push", dir, message };
220
+ execFileSync("git", ["-C", dir, "add", "-A"], { stdio: "inherit" });
221
+ let dirty = true;
222
+ try {
223
+ execFileSync("git", ["-C", dir, "diff", "--cached", "--quiet"], {
224
+ stdio: "pipe",
225
+ });
226
+ dirty = false;
227
+ } catch {
228
+ dirty = true;
229
+ }
230
+ if (dirty) {
231
+ execFileSync(
232
+ "git",
233
+ ["-C", dir, "commit", "-m", message || "Update personal AI skills/rules"],
234
+ { stdio: "inherit" }
235
+ );
236
+ }
237
+ execFileSync("git", ["-C", dir, "push"], { stdio: "inherit" });
238
+ return { action: "pushed", dir, committed: dirty };
239
+ }
240
+
216
241
  module.exports = {
217
242
  DEFAULT_REMOTE,
218
243
  machineConfigPath,
@@ -230,4 +255,7 @@ module.exports = {
230
255
  listRuleNames,
231
256
  symlinkState,
232
257
  agentSkillTargets,
258
+ gitClone,
259
+ gitPull,
260
+ gitPush,
233
261
  };
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { expandHome } = require("../config-paths");
7
+
8
+ /** Built-in harness registry. codex aliases agents (shared ~/.agents/skills). */
9
+ const BUILTINS = {
10
+ cursor: {
11
+ id: "cursor",
12
+ tier: "A",
13
+ format: "mdc",
14
+ emitter: "full",
15
+ skills: "~/.cursor/skills",
16
+ rules: "~/.cursor/rules",
17
+ unique: true,
18
+ aliasOf: null,
19
+ detect: { dirs: ["~/.cursor"], bins: ["cursor", "agent"] },
20
+ },
21
+ claude: {
22
+ id: "claude",
23
+ tier: "A",
24
+ format: "md",
25
+ emitter: "full",
26
+ skills: "~/.claude/skills",
27
+ rules: "~/.claude/rules",
28
+ unique: true,
29
+ aliasOf: null,
30
+ detect: { dirs: ["~/.claude"], bins: ["claude"] },
31
+ },
32
+ agents: {
33
+ id: "agents",
34
+ tier: "A",
35
+ format: "skills-only",
36
+ emitter: "full",
37
+ skills: "~/.agents/skills",
38
+ rules: null,
39
+ unique: false,
40
+ aliasOf: null,
41
+ detect: { dirs: ["~/.agents", "~/.codex"], bins: ["codex"] },
42
+ },
43
+ codex: {
44
+ id: "codex",
45
+ tier: "A",
46
+ format: "skills-only",
47
+ emitter: "full",
48
+ skills: "~/.agents/skills",
49
+ rules: null,
50
+ unique: false,
51
+ aliasOf: "agents",
52
+ detect: { dirs: ["~/.codex", "~/.agents"], bins: ["codex"] },
53
+ },
54
+ gemini: {
55
+ id: "gemini",
56
+ tier: "A",
57
+ format: "skills-only",
58
+ emitter: "full",
59
+ skills: "~/.gemini/skills",
60
+ rules: null,
61
+ unique: true,
62
+ aliasOf: null,
63
+ detect: { dirs: ["~/.gemini"], bins: ["gemini"] },
64
+ },
65
+ opencode: {
66
+ id: "opencode",
67
+ tier: "A",
68
+ format: "skills-only",
69
+ emitter: "full",
70
+ skills: "~/.config/opencode/skills",
71
+ rules: null,
72
+ unique: true,
73
+ aliasOf: null,
74
+ detect: { dirs: ["~/.config/opencode"], bins: ["opencode"] },
75
+ },
76
+ copilot: {
77
+ id: "copilot",
78
+ tier: "B",
79
+ format: "skills-only",
80
+ emitter: "stub",
81
+ skills: "~/.copilot/skills",
82
+ rules: null,
83
+ unique: true,
84
+ aliasOf: null,
85
+ detect: { dirs: ["~/.copilot"], bins: ["copilot"] },
86
+ },
87
+ windsurf: {
88
+ id: "windsurf",
89
+ tier: "B",
90
+ format: "skills-only",
91
+ emitter: "stub",
92
+ skills: null,
93
+ rules: null,
94
+ unique: true,
95
+ aliasOf: null,
96
+ detect: { dirs: ["~/.codeium"], bins: ["windsurf"] },
97
+ },
98
+ grok: {
99
+ id: "grok",
100
+ tier: "B",
101
+ format: "skills-only",
102
+ emitter: "stub",
103
+ skills: null,
104
+ rules: null,
105
+ unique: true,
106
+ aliasOf: null,
107
+ detect: { dirs: ["~/.grok"], bins: ["grok"] },
108
+ },
109
+ };
110
+
111
+ function which(bin) {
112
+ const pathEnv = process.env.PATH || "";
113
+ const sep = process.platform === "win32" ? ";" : ":";
114
+ const exts =
115
+ process.platform === "win32"
116
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean)
117
+ : [""];
118
+ for (const dir of pathEnv.split(sep)) {
119
+ if (!dir) continue;
120
+ for (const ext of exts) {
121
+ const candidate = path.join(dir, bin + ext);
122
+ try {
123
+ fs.accessSync(candidate, fs.constants.X_OK);
124
+ return candidate;
125
+ } catch {
126
+ /* try next */
127
+ }
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function dirExists(p) {
134
+ try {
135
+ return fs.statSync(expandHome(p)).isDirectory();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * True if the harness looks installed on this machine.
143
+ * Custom harnesses: installed if any configured live parent exists, or always if --force-link.
144
+ */
145
+ function isHarnessInstalled(def, { forceLink = false } = {}) {
146
+ if (forceLink) return true;
147
+ if (!def) return false;
148
+ if (def.detect) {
149
+ for (const d of def.detect.dirs || []) {
150
+ if (dirExists(d)) return true;
151
+ }
152
+ for (const b of def.detect.bins || []) {
153
+ if (which(b)) return true;
154
+ }
155
+ }
156
+ // Custom / override: consider installed if skills or rules parent exists
157
+ if (def.skills && dirExists(path.dirname(expandHome(def.skills)))) return true;
158
+ if (def.rules && dirExists(path.dirname(expandHome(def.rules)))) return true;
159
+ // Built-in with no detect hit
160
+ if (BUILTINS[def.id] && def.emitter === "stub" && !def.skills) return false;
161
+ if (!BUILTINS[def.id] && (def.skills || def.rules)) {
162
+ // user-registered: require parent dir or force
163
+ return false;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function getBuiltin(id) {
169
+ return BUILTINS[String(id).toLowerCase()] || null;
170
+ }
171
+
172
+ function listBuiltinIds() {
173
+ return Object.keys(BUILTINS).sort();
174
+ }
175
+
176
+ /**
177
+ * Merge built-in + machine config harnesses.<id> overrides.
178
+ * @returns {object} resolved harness definition
179
+ */
180
+ function resolveHarness(id, cfg, oneShot = {}) {
181
+ const key = String(id).toLowerCase();
182
+ const builtin = getBuiltin(key);
183
+ const overrides = (cfg.machineConfig && cfg.machineConfig.harnesses) || {};
184
+ const over = overrides[key] || {};
185
+
186
+ const base = builtin
187
+ ? { ...builtin }
188
+ : {
189
+ id: key,
190
+ tier: "C",
191
+ format: "skills-only",
192
+ emitter: "full",
193
+ skills: null,
194
+ rules: null,
195
+ unique: true,
196
+ aliasOf: null,
197
+ detect: null,
198
+ custom: true,
199
+ };
200
+
201
+ const skills =
202
+ oneShot.skills ||
203
+ over.skills ||
204
+ base.skills;
205
+ const rules =
206
+ oneShot.rules !== undefined
207
+ ? oneShot.rules
208
+ : over.rules !== undefined
209
+ ? over.rules
210
+ : base.rules;
211
+ const format =
212
+ oneShot.format ||
213
+ over.format ||
214
+ base.format ||
215
+ (skills && rules ? "md" : "skills-only");
216
+
217
+ const enabled =
218
+ over.enabled !== undefined
219
+ ? Boolean(over.enabled)
220
+ : builtin
221
+ ? true
222
+ : true;
223
+
224
+ const distId = base.aliasOf || key;
225
+
226
+ return {
227
+ ...base,
228
+ id: key,
229
+ skills: skills ? expandHome(skills) : null,
230
+ rules: rules ? expandHome(rules) : null,
231
+ skillsRaw: skills || null,
232
+ rulesRaw: rules || null,
233
+ format,
234
+ enabled,
235
+ distId,
236
+ override: Boolean(over.skills || over.rules || over.format || over.enabled !== undefined),
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Expand enabled agent list: drop aliases that duplicate canonical, warn.
242
+ * Filters to installed harnesses unless forceLink.
243
+ */
244
+ function selectHarnesses(cfg, agentIds, { forceLink = false, includeUninstalled = false } = {}) {
245
+ const requested = (agentIds && agentIds.length ? agentIds : cfg.agents || ["cursor"]).map(
246
+ (a) => String(a).trim().toLowerCase()
247
+ );
248
+ const seenLive = new Map(); // live skills path -> id
249
+ const selected = [];
250
+ const skipped = [];
251
+ const warnings = [];
252
+
253
+ for (const id of requested) {
254
+ let def;
255
+ try {
256
+ def = resolveHarness(id, cfg);
257
+ } catch (e) {
258
+ skipped.push({ id, reason: e.message });
259
+ continue;
260
+ }
261
+ if (!def.enabled) {
262
+ skipped.push({ id, reason: "disabled" });
263
+ continue;
264
+ }
265
+ if (def.emitter === "stub" && !def.skills && !def.rules) {
266
+ skipped.push({ id, reason: "stub_no_paths" });
267
+ continue;
268
+ }
269
+ if (!includeUninstalled && !isHarnessInstalled(def, { forceLink })) {
270
+ skipped.push({ id, reason: "not_installed" });
271
+ continue;
272
+ }
273
+ if (def.aliasOf) {
274
+ warnings.push(`${id} is an alias of ${def.aliasOf}; using dist/${def.distId}`);
275
+ }
276
+ const liveKey = def.skills || def.rules || def.id;
277
+ if (seenLive.has(liveKey)) {
278
+ warnings.push(
279
+ `skip ${id}: shares live path with ${seenLive.get(liveKey)} (${liveKey})`
280
+ );
281
+ skipped.push({ id, reason: "shared_live_path", with: seenLive.get(liveKey) });
282
+ continue;
283
+ }
284
+ seenLive.set(liveKey, def.distId);
285
+ selected.push(def);
286
+ }
287
+
288
+ return { selected, skipped, warnings };
289
+ }
290
+
291
+ function ensureAgentSourceDirs(cfg, harnessId) {
292
+ const id = harnessId === "codex" ? "agents" : harnessId;
293
+ const root = path.join(cfg.dir, "agents", id);
294
+ fs.mkdirSync(path.join(root, "rules"), { recursive: true });
295
+ fs.mkdirSync(path.join(root, "skills"), { recursive: true });
296
+ return root;
297
+ }
298
+
299
+ module.exports = {
300
+ BUILTINS,
301
+ getBuiltin,
302
+ listBuiltinIds,
303
+ resolveHarness,
304
+ selectHarnesses,
305
+ isHarnessInstalled,
306
+ ensureAgentSourceDirs,
307
+ which,
308
+ dirExists,
309
+ };