@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/lib/config.js CHANGED
@@ -1,22 +1,19 @@
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");
7
+ const { detectDefaultRemote } = require("./remote");
6
8
 
7
- const DEFAULT_REMOTE = "https://github.com/dujavi/.ai-md.git";
9
+ /** @deprecated No hardcoded personal default — use detectDefaultRemote(). */
10
+ const DEFAULT_REMOTE = null;
8
11
 
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
12
  function machineConfigPath(env = process.env) {
18
13
  if (env.AI_MD_CONFIG) return expandHome(env.AI_MD_CONFIG);
19
- const xdg = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
14
+ const xdg =
15
+ env.XDG_CONFIG_HOME ||
16
+ path.join(require("os").homedir(), ".config");
20
17
  return path.join(xdg, "ai-md", "config.json");
21
18
  }
22
19
 
@@ -36,20 +33,16 @@ function readMachineConfig(env = process.env) {
36
33
  }
37
34
  }
38
35
 
39
- function writeMachineConfig({ dir, remote }, env = process.env, { dryRun = false } = {}) {
36
+ function writeMachineConfig(patch, env = process.env, { dryRun = false } = {}) {
40
37
  const configPath = machineConfigPath(env);
41
38
  const existing = readMachineConfig(env).raw || {};
42
39
  const next = {
43
40
  ...existing,
44
- ...(dir != null ? { dir: expandHome(dir) } : {}),
45
- ...(remote != null ? { remote: String(remote) } : {}),
41
+ ...patch,
46
42
  updatedAt: new Date().toISOString(),
47
43
  };
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
- }
44
+ if (patch.dir != null) next.dir = expandHome(patch.dir);
45
+ if (patch.remote != null) next.remote = String(patch.remote);
53
46
  if (dryRun) {
54
47
  return { action: "would_write", path: configPath, config: next };
55
48
  }
@@ -65,11 +58,9 @@ function writeMachineConfig({ dir, remote }, env = process.env, { dryRun = false
65
58
  return { action: "wrote", path: configPath, config: next };
66
59
  }
67
60
 
68
- /**
69
- * Precedence: explicit opts > env > machine config file > defaults.
70
- * @param {{ dir?: string, remote?: string }} [opts]
71
- */
72
61
  function resolveConfig(env = process.env, opts = {}) {
62
+ const os = require("os");
63
+ const { defaultLinkMode } = require("./config-paths");
73
64
  const home = os.homedir();
74
65
  const stored = readMachineConfig(env);
75
66
  const defaultDir = path.join(home, ".ai-md");
@@ -77,11 +68,25 @@ function resolveConfig(env = process.env, opts = {}) {
77
68
  const envDir = env.AI_MD_DIR || env.CURSOR_MD_DIR || null;
78
69
  const envRemote = env.AI_MD_REMOTE || env.CURSOR_MD_REMOTE || null;
79
70
 
80
- const dir = expandHome(
81
- opts.dir || envDir || stored.dir || defaultDir
82
- );
83
- const remote =
84
- opts.remote || envRemote || stored.remote || DEFAULT_REMOTE;
71
+ const dir = expandHome(opts.dir || envDir || stored.dir || defaultDir);
72
+
73
+ let remote = opts.remote || envRemote || stored.remote || null;
74
+ let remoteSource = opts.remote
75
+ ? "flag"
76
+ : envRemote
77
+ ? "env"
78
+ : stored.remote
79
+ ? "config"
80
+ : "none";
81
+ let remoteDetection = null;
82
+
83
+ if (!remote && opts.skipRemoteDetect !== true) {
84
+ remoteDetection = detectDefaultRemote();
85
+ if (remoteDetection.remote) {
86
+ remote = remoteDetection.remote;
87
+ remoteSource = "detected";
88
+ }
89
+ }
85
90
 
86
91
  const sources = {
87
92
  dir: opts.dir
@@ -91,38 +96,53 @@ function resolveConfig(env = process.env, opts = {}) {
91
96
  : stored.dir
92
97
  ? "config"
93
98
  : "default",
94
- remote: opts.remote
95
- ? "flag"
96
- : envRemote && !(stored.remote && envRemote === stored.remote)
97
- ? "env"
98
- : stored.remote
99
- ? "config"
100
- : "default",
99
+ remote: remoteSource,
101
100
  };
102
101
 
102
+ const raw = stored.raw || {};
103
+ const agents = Array.isArray(raw.agents) && raw.agents.length
104
+ ? raw.agents.map((a) => String(a).toLowerCase())
105
+ : ["cursor"];
106
+ const linkMode = opts.linkMode || raw.linkMode || defaultLinkMode();
107
+
103
108
  const templatesDir = path.join(dir, "templates");
104
109
  return {
105
110
  home,
106
111
  dir,
107
112
  remote,
108
113
  sources,
114
+ remoteDetection,
115
+ agents,
116
+ linkMode,
109
117
  machineConfigPath: stored.path,
110
- machineConfig: stored.raw,
118
+ machineConfig: raw,
111
119
  cursorSkills: path.join(home, ".cursor", "skills"),
112
120
  cursorRules: path.join(home, ".cursor", "rules"),
113
121
  projectsDir: path.join(dir, "projects"),
114
122
  templatesDir,
115
123
  templateDir: path.join(templatesDir, "base"),
116
- skillsDir: path.join(dir, "skills"),
117
- rulesDir: path.join(dir, "rules"),
124
+ // legacy aliases → shared (build uses shared/)
125
+ skillsDir: path.join(dir, "shared", "skills"),
126
+ rulesDir: path.join(dir, "shared", "rules"),
127
+ sharedDir: path.join(dir, "shared"),
128
+ agentsDir: path.join(dir, "agents"),
129
+ distDir: path.join(dir, "dist"),
118
130
  };
119
131
  }
120
132
 
121
133
  function applyEnvFromConfig(cfg) {
122
134
  process.env.AI_MD_DIR = cfg.dir;
123
- process.env.AI_MD_REMOTE = cfg.remote;
124
135
  process.env.CURSOR_MD_DIR = cfg.dir;
125
- process.env.CURSOR_MD_REMOTE = cfg.remote;
136
+ // Never invent remotes into the environment (avoids fake defaults and
137
+ // flipping sources.remote to "env" on re-resolve). Only pass through when
138
+ // the process already had one, or the caller passed --remote (flag).
139
+ if (
140
+ cfg.remote &&
141
+ (cfg.sources.remote === "env" || cfg.sources.remote === "flag")
142
+ ) {
143
+ process.env.AI_MD_REMOTE = cfg.remote;
144
+ process.env.CURSOR_MD_REMOTE = cfg.remote;
145
+ }
126
146
  }
127
147
 
128
148
  function templatePath(cfg, name = "base") {
@@ -142,16 +162,14 @@ function countRules(rulesDir) {
142
162
  if (!fs.existsSync(rulesDir)) return 0;
143
163
  return fs
144
164
  .readdirSync(rulesDir)
145
- .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
146
- .length;
165
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md")).length;
147
166
  }
148
167
 
149
168
  function countSkills(skillsDir) {
150
169
  if (!fs.existsSync(skillsDir)) return 0;
151
170
  return fs
152
171
  .readdirSync(skillsDir, { withFileTypes: true })
153
- .filter((d) => d.isDirectory() && !d.name.startsWith("."))
154
- .length;
172
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".")).length;
155
173
  }
156
174
 
157
175
  function listSkillNames(skillsDir) {
@@ -172,33 +190,24 @@ function listRuleNames(rulesDir) {
172
190
  }
173
191
 
174
192
  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 };
193
+ const { linkState } = require("./link");
194
+ return linkState(linkPath, expected);
189
195
  }
190
196
 
197
+ /** @deprecated use harnesses.selectHarnesses */
191
198
  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
- };
199
+ const { resolveHarness } = require("./harnesses");
200
+ const cfg = { home, machineConfig: {} };
197
201
  return (agents || [])
198
- .map((a) => String(a).trim().toLowerCase())
199
- .filter(Boolean)
200
- .filter((name) => map[name])
201
- .map((name) => ({ name, path: map[name] }));
202
+ .map((a) => {
203
+ try {
204
+ const h = resolveHarness(a, { ...cfg, home });
205
+ return h.skills ? { name: h.id, path: h.skills } : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ })
210
+ .filter(Boolean);
202
211
  }
203
212
 
204
213
  function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
@@ -213,6 +222,51 @@ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
213
222
  return { action: "migrated", from: legacy, to: dest };
214
223
  }
215
224
 
225
+ function gitClone(remote, dir, { dryRun = false } = {}) {
226
+ if (!remote) {
227
+ const err = new Error(
228
+ "git clone requires a remote URL (pass --remote or set AI_MD_REMOTE)"
229
+ );
230
+ err.code = "EINVAL";
231
+ throw err;
232
+ }
233
+ if (dryRun) return { action: "would_clone", remote, dir };
234
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
235
+ execFileSync("git", ["clone", remote, dir], { stdio: "inherit" });
236
+ return { action: "cloned", remote, dir };
237
+ }
238
+
239
+ function gitPull(dir, { dryRun = false } = {}) {
240
+ if (dryRun) return { action: "would_pull", dir };
241
+ execFileSync("git", ["-C", dir, "pull", "--rebase", "--autostash"], {
242
+ stdio: "inherit",
243
+ });
244
+ return { action: "pulled", dir };
245
+ }
246
+
247
+ function gitPush(dir, { message, dryRun = false } = {}) {
248
+ if (dryRun) return { action: "would_push", dir, message };
249
+ execFileSync("git", ["-C", dir, "add", "-A"], { stdio: "inherit" });
250
+ let dirty = true;
251
+ try {
252
+ execFileSync("git", ["-C", dir, "diff", "--cached", "--quiet"], {
253
+ stdio: "pipe",
254
+ });
255
+ dirty = false;
256
+ } catch {
257
+ dirty = true;
258
+ }
259
+ if (dirty) {
260
+ execFileSync(
261
+ "git",
262
+ ["-C", dir, "commit", "-m", message || "Update personal AI skills/rules"],
263
+ { stdio: "inherit" }
264
+ );
265
+ }
266
+ execFileSync("git", ["-C", dir, "push"], { stdio: "inherit" });
267
+ return { action: "pushed", dir, committed: dirty };
268
+ }
269
+
216
270
  module.exports = {
217
271
  DEFAULT_REMOTE,
218
272
  machineConfigPath,
@@ -230,4 +284,7 @@ module.exports = {
230
284
  listRuleNames,
231
285
  symlinkState,
232
286
  agentSkillTargets,
287
+ gitClone,
288
+ gitPull,
289
+ gitPush,
233
290
  };
@@ -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
+ };