@dujavi/ai-md 0.3.0 → 0.4.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 CHANGED
@@ -10,12 +10,24 @@ Public **AXI-shaped** CLI for a **private** personal AI config directory (`~/.ai
10
10
 
11
11
  No personal content ships in this package. Reads default to TOON + `help[]` (`--json` available).
12
12
 
13
- ## Install
13
+ ## New machine
14
14
 
15
15
  ```bash
16
16
  npm i -g @dujavi/ai-md
17
+ ai-md setup --remote https://github.com/<you>/.ai-md.git --tools
18
+ # persists ~/.config/ai-md/config.json then clones + links (+ optional grok/quota-axi)
17
19
  ```
18
20
 
21
+ Or step by step:
22
+
23
+ ```bash
24
+ ai-md config set --remote https://github.com/<you>/.ai-md.git --dir ~/.ai-md
25
+ ai-md install
26
+ ai-md ensure-tools
27
+ ```
28
+
29
+ Precedence: `--remote`/`--dir` flags > `AI_MD_*` env > `~/.config/ai-md/config.json` > defaults.
30
+
19
31
  ## Layout idea
20
32
 
21
33
  ```text
@@ -45,5 +57,5 @@ ai-md ensure-tools
45
57
 
46
58
  ## Environment
47
59
 
48
- - `AI_MD_DIR` `~/.ai-md`
49
- - `AI_MD_REMOTE` private content git URL
60
+ - `AI_MD_DIR` / `AI_MD_REMOTE` — override for one shot (also set by the CLI after reading config)
61
+ - `AI_MD_CONFIG` path to machine config JSON (default `~/.config/ai-md/config.json`)
package/bin/ai-md.js CHANGED
@@ -3,7 +3,13 @@
3
3
 
4
4
  const { spawnSync } = require("child_process");
5
5
  const path = require("path");
6
- const { resolveConfig } = require("../lib/config");
6
+ const {
7
+ resolveConfig,
8
+ applyEnvFromConfig,
9
+ writeMachineConfig,
10
+ readMachineConfig,
11
+ expandHome,
12
+ } = require("../lib/config");
7
13
  const { emit, fail } = require("../lib/output");
8
14
  const { collectStatus, statusHelp } = require("../lib/status");
9
15
  const {
@@ -16,12 +22,6 @@ const {
16
22
  } = require("../lib/commands");
17
23
 
18
24
  const scriptsDir = path.join(__dirname, "..", "scripts");
19
- const cfg = resolveConfig();
20
-
21
- process.env.AI_MD_DIR = cfg.dir;
22
- process.env.AI_MD_REMOTE = cfg.remote;
23
- process.env.CURSOR_MD_DIR = cfg.dir;
24
- process.env.CURSOR_MD_REMOTE = cfg.remote;
25
25
 
26
26
  function parseArgs(argv) {
27
27
  const out = {
@@ -31,11 +31,14 @@ function parseArgs(argv) {
31
31
  force: false,
32
32
  dryRun: false,
33
33
  fix: false,
34
+ tools: false,
34
35
  message: null,
35
36
  repo: null,
36
37
  name: null,
37
38
  project: null,
38
39
  from: "base",
40
+ remote: null,
41
+ dir: null,
39
42
  agents: ["cursor"],
40
43
  rest: [],
41
44
  };
@@ -50,7 +53,6 @@ function parseArgs(argv) {
50
53
  return out;
51
54
  }
52
55
  if (first.startsWith("-")) {
53
- // flags before command → treat as status with flags
54
56
  out.cmd = "status";
55
57
  } else {
56
58
  out.cmd = args.shift();
@@ -74,6 +76,9 @@ function parseArgs(argv) {
74
76
  out.fix = true;
75
77
  out.force = true;
76
78
  break;
79
+ case "--tools":
80
+ out.tools = true;
81
+ break;
77
82
  case "-m":
78
83
  case "--message":
79
84
  out.message = args.shift();
@@ -90,6 +95,12 @@ function parseArgs(argv) {
90
95
  case "--from":
91
96
  out.from = args.shift();
92
97
  break;
98
+ case "--remote":
99
+ out.remote = args.shift();
100
+ break;
101
+ case "--dir":
102
+ out.dir = expandHome(args.shift());
103
+ break;
93
104
  case "--agents":
94
105
  out.agents = String(args.shift() || "cursor")
95
106
  .split(",")
@@ -100,6 +111,11 @@ function parseArgs(argv) {
100
111
  case "--help":
101
112
  out.cmdHelp = true;
102
113
  break;
114
+ case "set":
115
+ case "show":
116
+ // subcommand for `ai-md config set|show`
117
+ out.rest.push(a);
118
+ break;
103
119
  default:
104
120
  if (a.startsWith("-")) {
105
121
  fail(`unknown flag: ${a}`, {
@@ -128,7 +144,13 @@ Layout:
128
144
  ~/.ai-md/templates/<type> Project-type starters (default: base)
129
145
  ~/.ai-md/projects/<name> Per-app overlays (repo .cursor → here)
130
146
 
147
+ Machine config (persisted):
148
+ ~/.config/ai-md/config.json dir + remote (override with AI_MD_CONFIG)
149
+ Precedence: --flag > env > config file > defaults
150
+
131
151
  Commands:
152
+ setup First-time machine setup: save config, install, optional --tools
153
+ config Show persisted config (or: config set --remote/--dir)
132
154
  status Snapshot (default when no command) [AXI]
133
155
  doctor Diagnose links/projects; --fix repairs
134
156
  install Clone remote if needed; link ~/.cursor + optional agents
@@ -140,31 +162,34 @@ Commands:
140
162
  help Show this help
141
163
 
142
164
  Options:
143
- --json JSON instead of TOON (status/doctor/init/…)
165
+ --remote <url> Private content git remote (persisted by setup/config set/install)
166
+ --dir <path> Local AI_MD_DIR (default ~/.ai-md; persisted same way)
167
+ --json JSON instead of TOON
144
168
  --full Include paths and drift details
145
169
  --agents <list> Skill link targets: cursor,claude,agents (default: cursor)
170
+ --tools With setup: also run ensure-tools
146
171
  --repo <path> App repository root
147
- --name <id> Project id under projects/ (default: basename)
172
+ --name <id> Project id under projects/
148
173
  --project <id> Target project for apply-template
149
174
  --from <id> Template under templates/ (default: base)
150
- --force Replace non-symlink .cursor / re-merge
175
+ --force Replace non-symlink paths
151
176
  --dry-run Preview without writing
152
177
  --fix doctor: repair symlinks
153
178
  -m, --message push commit message
154
179
 
155
180
  Examples:
156
- ai-md
157
- ai-md status --json
181
+ ai-md setup --remote https://github.com/<you>/.ai-md.git --tools
182
+ ai-md config set --remote https://github.com/<you>/.ai-md.git --dir ~/.ai-md
183
+ ai-md config
184
+ ai-md install --remote https://github.com/<you>/.ai-md.git
158
185
  ai-md init-project --repo ~/presenter --from base
159
- ai-md apply-template --project presenter --from base
160
- ai-md doctor --fix --agents cursor,claude
161
186
  `);
162
187
  }
163
188
 
164
- function runBash(script, args) {
189
+ function runBash(script, args, env) {
165
190
  const result = spawnSync("bash", [path.join(scriptsDir, script), ...args], {
166
191
  stdio: "inherit",
167
- env: process.env,
192
+ env,
168
193
  });
169
194
  if (result.error) {
170
195
  fail(result.error.message, { exitCode: 1 });
@@ -173,6 +198,37 @@ function runBash(script, args) {
173
198
  process.exit(result.status === null ? 1 : result.status);
174
199
  }
175
200
 
201
+ function persistIfRequested(opts) {
202
+ if (opts.remote == null && opts.dir == null) return null;
203
+ return writeMachineConfig(
204
+ { dir: opts.dir, remote: opts.remote },
205
+ process.env,
206
+ { dryRun: opts.dryRun }
207
+ );
208
+ }
209
+
210
+ function runInstall(cfg, opts) {
211
+ const bashArgs = ["install"];
212
+ if (opts.force) bashArgs.push("--force");
213
+ if (opts.dryRun) bashArgs.push("--dry-run");
214
+ const result = spawnSync(
215
+ "bash",
216
+ [path.join(scriptsDir, "sync-config.sh"), ...bashArgs],
217
+ { stdio: "inherit", env: process.env }
218
+ );
219
+ if (result.status !== 0) {
220
+ process.exit(result.status === null ? 1 : result.status);
221
+ }
222
+ const links = [
223
+ ...ensureCursorLinks(cfg, { force: opts.force, dryRun: opts.dryRun }),
224
+ ...ensureAgentSkillLinks(cfg, opts.agents, {
225
+ force: opts.force,
226
+ dryRun: opts.dryRun,
227
+ }),
228
+ ];
229
+ return links;
230
+ }
231
+
176
232
  function main() {
177
233
  const opts = parseArgs(process.argv.slice(2));
178
234
 
@@ -181,8 +237,116 @@ function main() {
181
237
  return;
182
238
  }
183
239
 
240
+ // Resolve after flags so --dir/--remote apply
241
+ let cfg = resolveConfig(process.env, {
242
+ dir: opts.dir || undefined,
243
+ remote: opts.remote || undefined,
244
+ });
245
+ applyEnvFromConfig(cfg);
246
+
184
247
  try {
185
248
  switch (opts.cmd) {
249
+ case "config": {
250
+ const sub = opts.rest[0] || "show";
251
+ if (sub === "set") {
252
+ if (opts.remote == null && opts.dir == null) {
253
+ fail("config set requires --remote and/or --dir", {
254
+ exitCode: 2,
255
+ json: opts.json,
256
+ help: [
257
+ "ai-md config set --remote https://github.com/<you>/.ai-md.git --dir ~/.ai-md",
258
+ ],
259
+ });
260
+ process.exit(2);
261
+ }
262
+ const saved = writeMachineConfig(
263
+ { dir: opts.dir, remote: opts.remote },
264
+ process.env,
265
+ { dryRun: opts.dryRun }
266
+ );
267
+ cfg = resolveConfig(process.env);
268
+ emit({
269
+ data: { ...saved, resolved: { dir: cfg.dir, remote: cfg.remote, sources: cfg.sources } },
270
+ json: opts.json,
271
+ help: [
272
+ "Run `ai-md install` if ~/.ai-md is not cloned yet",
273
+ "Run `ai-md setup --remote <url> --tools` for first-time machine bootstrap",
274
+ ],
275
+ });
276
+ break;
277
+ }
278
+ const stored = readMachineConfig();
279
+ cfg = resolveConfig(process.env);
280
+ emit({
281
+ data: {
282
+ path: stored.path,
283
+ stored: stored.raw,
284
+ resolved: {
285
+ dir: cfg.dir,
286
+ remote: cfg.remote,
287
+ sources: cfg.sources,
288
+ },
289
+ },
290
+ json: opts.json,
291
+ help: [
292
+ "ai-md config set --remote <url> --dir ~/.ai-md",
293
+ "Flags and env override the config file",
294
+ ],
295
+ });
296
+ break;
297
+ }
298
+ case "setup": {
299
+ if (!opts.remote && !cfg.machineConfig?.remote && cfg.sources.remote === "default") {
300
+ // Allow default for dujavi, but recommend explicit remote for others via help
301
+ }
302
+ const saved = writeMachineConfig(
303
+ {
304
+ dir: opts.dir || cfg.dir,
305
+ remote: opts.remote || cfg.remote,
306
+ },
307
+ process.env,
308
+ { dryRun: opts.dryRun }
309
+ );
310
+ cfg = resolveConfig(process.env, {
311
+ dir: opts.dir || undefined,
312
+ remote: opts.remote || undefined,
313
+ });
314
+ applyEnvFromConfig(cfg);
315
+ const links = opts.dryRun
316
+ ? []
317
+ : runInstall(cfg, opts);
318
+ let tools = null;
319
+ if (opts.tools && !opts.dryRun) {
320
+ const tr = spawnSync(
321
+ "bash",
322
+ [path.join(scriptsDir, "ensure-agent-tools.sh")],
323
+ { stdio: "inherit", env: process.env }
324
+ );
325
+ tools = { exitCode: tr.status };
326
+ }
327
+ const data = collectStatus({
328
+ full: opts.full,
329
+ agents: opts.agents,
330
+ from: opts.from,
331
+ });
332
+ emit({
333
+ data: {
334
+ setup: "ok",
335
+ config: saved,
336
+ links,
337
+ tools,
338
+ ...data,
339
+ },
340
+ json: opts.json,
341
+ help: [
342
+ opts.tools
343
+ ? "Run `ai-md status` to verify"
344
+ : "Run `ai-md ensure-tools` (or re-run setup with --tools)",
345
+ "Run `ai-md init-project --repo <path> --from base` for a new app",
346
+ ],
347
+ });
348
+ break;
349
+ }
186
350
  case "status": {
187
351
  const data = collectStatus({
188
352
  full: opts.full,
@@ -260,28 +424,18 @@ function main() {
260
424
  break;
261
425
  }
262
426
  case "install": {
263
- // clone/pull via bash, then Node links (incl. agents)
264
- const bashArgs = ["install"];
265
- if (opts.force) bashArgs.push("--force");
266
- if (opts.dryRun) bashArgs.push("--dry-run");
267
- const result = spawnSync(
268
- "bash",
269
- [path.join(scriptsDir, "sync-config.sh"), ...bashArgs],
270
- { stdio: "inherit", env: process.env }
271
- );
272
- if (result.status !== 0) {
273
- process.exit(result.status === null ? 1 : result.status);
427
+ const saved = persistIfRequested(opts);
428
+ if (saved) {
429
+ cfg = resolveConfig(process.env, {
430
+ dir: opts.dir || undefined,
431
+ remote: opts.remote || undefined,
432
+ });
433
+ applyEnvFromConfig(cfg);
274
434
  }
275
- const links = [
276
- ...ensureCursorLinks(cfg, { force: opts.force, dryRun: opts.dryRun }),
277
- ...ensureAgentSkillLinks(cfg, opts.agents, {
278
- force: opts.force,
279
- dryRun: opts.dryRun,
280
- }),
281
- ];
435
+ const links = runInstall(cfg, opts);
282
436
  const data = collectStatus({ full: opts.full, agents: opts.agents });
283
437
  emit({
284
- data: { install: "ok", links, ...data },
438
+ data: { install: "ok", config: saved, links, ...data },
285
439
  json: opts.json,
286
440
  help: [
287
441
  "Run `ai-md ensure-tools` to install grok + quota-axi",
@@ -291,23 +445,26 @@ function main() {
291
445
  break;
292
446
  }
293
447
  case "pull":
294
- runBash("sync-config.sh", [
295
- "pull",
296
- ...(opts.dryRun ? ["--dry-run"] : []),
297
- ]);
448
+ runBash(
449
+ "sync-config.sh",
450
+ ["pull", ...(opts.dryRun ? ["--dry-run"] : [])],
451
+ process.env
452
+ );
298
453
  break;
299
454
  case "push": {
300
455
  const args = ["push"];
301
456
  if (opts.message) args.push("-m", opts.message);
302
457
  if (opts.dryRun) args.push("--dry-run");
303
- runBash("sync-config.sh", args);
458
+ runBash("sync-config.sh", args, process.env);
304
459
  break;
305
460
  }
306
461
  case "ensure-tools":
307
462
  case "tools":
308
- runBash("ensure-agent-tools.sh", [
309
- ...(opts.dryRun ? ["--dry-run"] : []),
310
- ]);
463
+ runBash(
464
+ "ensure-agent-tools.sh",
465
+ [...(opts.dryRun ? ["--dry-run"] : [])],
466
+ process.env
467
+ );
311
468
  break;
312
469
  default:
313
470
  fail(`unknown command: ${opts.cmd}`, {
package/lib/config.js CHANGED
@@ -4,30 +4,127 @@ const fs = require("fs");
4
4
  const os = require("os");
5
5
  const path = require("path");
6
6
 
7
- function resolveConfig(env = process.env) {
7
+ const DEFAULT_REMOTE = "https://github.com/dujavi/.ai-md.git";
8
+
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
+ function machineConfigPath(env = process.env) {
18
+ if (env.AI_MD_CONFIG) return expandHome(env.AI_MD_CONFIG);
19
+ const xdg = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
20
+ return path.join(xdg, "ai-md", "config.json");
21
+ }
22
+
23
+ function readMachineConfig(env = process.env) {
24
+ const configPath = machineConfigPath(env);
25
+ try {
26
+ const raw = fs.readFileSync(configPath, "utf8");
27
+ const data = JSON.parse(raw);
28
+ return {
29
+ path: configPath,
30
+ dir: data.dir ? expandHome(String(data.dir)) : null,
31
+ remote: data.remote ? String(data.remote) : null,
32
+ raw: data,
33
+ };
34
+ } catch {
35
+ return { path: configPath, dir: null, remote: null, raw: null };
36
+ }
37
+ }
38
+
39
+ function writeMachineConfig({ dir, remote }, env = process.env, { dryRun = false } = {}) {
40
+ const configPath = machineConfigPath(env);
41
+ const existing = readMachineConfig(env).raw || {};
42
+ const next = {
43
+ ...existing,
44
+ ...(dir != null ? { dir: expandHome(dir) } : {}),
45
+ ...(remote != null ? { remote: String(remote) } : {}),
46
+ updatedAt: new Date().toISOString(),
47
+ };
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
+ }
53
+ if (dryRun) {
54
+ return { action: "would_write", path: configPath, config: next };
55
+ }
56
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
57
+ fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, {
58
+ mode: 0o600,
59
+ });
60
+ try {
61
+ fs.chmodSync(configPath, 0o600);
62
+ } catch {
63
+ /* best effort */
64
+ }
65
+ return { action: "wrote", path: configPath, config: next };
66
+ }
67
+
68
+ /**
69
+ * Precedence: explicit opts > env > machine config file > defaults.
70
+ * @param {{ dir?: string, remote?: string }} [opts]
71
+ */
72
+ function resolveConfig(env = process.env, opts = {}) {
8
73
  const home = os.homedir();
9
- const dir =
10
- env.AI_MD_DIR || env.CURSOR_MD_DIR || path.join(home, ".ai-md");
74
+ const stored = readMachineConfig(env);
75
+ const defaultDir = path.join(home, ".ai-md");
76
+
77
+ const envDir = env.AI_MD_DIR || env.CURSOR_MD_DIR || null;
78
+ const envRemote = env.AI_MD_REMOTE || env.CURSOR_MD_REMOTE || null;
79
+
80
+ const dir = expandHome(
81
+ opts.dir || envDir || stored.dir || defaultDir
82
+ );
11
83
  const remote =
12
- env.AI_MD_REMOTE ||
13
- env.CURSOR_MD_REMOTE ||
14
- "https://github.com/dujavi/.ai-md.git";
84
+ opts.remote || envRemote || stored.remote || DEFAULT_REMOTE;
85
+
86
+ const sources = {
87
+ dir: opts.dir
88
+ ? "flag"
89
+ : envDir && !(stored.dir && expandHome(envDir) === stored.dir)
90
+ ? "env"
91
+ : stored.dir
92
+ ? "config"
93
+ : "default",
94
+ remote: opts.remote
95
+ ? "flag"
96
+ : envRemote && !(stored.remote && envRemote === stored.remote)
97
+ ? "env"
98
+ : stored.remote
99
+ ? "config"
100
+ : "default",
101
+ };
102
+
15
103
  const templatesDir = path.join(dir, "templates");
16
104
  return {
17
105
  home,
18
106
  dir,
19
107
  remote,
108
+ sources,
109
+ machineConfigPath: stored.path,
110
+ machineConfig: stored.raw,
20
111
  cursorSkills: path.join(home, ".cursor", "skills"),
21
112
  cursorRules: path.join(home, ".cursor", "rules"),
22
113
  projectsDir: path.join(dir, "projects"),
23
114
  templatesDir,
24
- /** @deprecated use templatePath(cfg, name) */
25
115
  templateDir: path.join(templatesDir, "base"),
26
116
  skillsDir: path.join(dir, "skills"),
27
117
  rulesDir: path.join(dir, "rules"),
28
118
  };
29
119
  }
30
120
 
121
+ function applyEnvFromConfig(cfg) {
122
+ process.env.AI_MD_DIR = cfg.dir;
123
+ process.env.AI_MD_REMOTE = cfg.remote;
124
+ process.env.CURSOR_MD_DIR = cfg.dir;
125
+ process.env.CURSOR_MD_REMOTE = cfg.remote;
126
+ }
127
+
31
128
  function templatePath(cfg, name = "base") {
32
129
  return path.join(cfg.templatesDir, name);
33
130
  }
@@ -104,7 +201,6 @@ function agentSkillTargets(home, agents) {
104
201
  .map((name) => ({ name, path: map[name] }));
105
202
  }
106
203
 
107
- /** One-time: projects/template → templates/base */
108
204
  function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
109
205
  const legacy = path.join(cfg.projectsDir, "template");
110
206
  const dest = templatePath(cfg, "base");
@@ -118,7 +214,13 @@ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
118
214
  }
119
215
 
120
216
  module.exports = {
217
+ DEFAULT_REMOTE,
218
+ machineConfigPath,
219
+ readMachineConfig,
220
+ writeMachineConfig,
121
221
  resolveConfig,
222
+ applyEnvFromConfig,
223
+ expandHome,
122
224
  templatePath,
123
225
  listTemplates,
124
226
  migrateLegacyTemplate,
package/lib/status.js CHANGED
@@ -135,6 +135,9 @@ function collectStatus(opts = {}) {
135
135
  generatedAt: new Date().toISOString(),
136
136
  dir: cfg.dir,
137
137
  remote: remote || cfg.remote,
138
+ sources: cfg.sources,
139
+ machineConfigPath: cfg.machineConfigPath,
140
+ machineConfig: cfg.machineConfig,
138
141
  branch,
139
142
  dirty,
140
143
  statusLine: aheadBehind,
@@ -173,7 +176,14 @@ function collectStatus(opts = {}) {
173
176
  function statusHelp(data) {
174
177
  const help = [];
175
178
  if (data.problems.includes("ai_md_missing") || data.problems.includes("ai_md_not_git")) {
176
- help.push("Run `ai-md install` to clone AI_MD_REMOTE → ~/.ai-md and link ~/.cursor/{skills,rules}");
179
+ help.push(
180
+ "Run `ai-md setup --remote <git-url>` (or `ai-md install --remote <git-url>`) to clone and link"
181
+ );
182
+ }
183
+ if (!data.machineConfig) {
184
+ help.push(
185
+ "Persist remote/dir with `ai-md setup --remote <url>` or `ai-md config set --remote <url> --dir ~/.ai-md`"
186
+ );
177
187
  }
178
188
  if (data.links.some((l) => l.state !== "ok")) {
179
189
  help.push("Run `ai-md doctor --fix` to repair ~/.cursor skills/rules symlinks");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dujavi/ai-md",
3
- "version": "0.3.0",
4
- "description": "AXI-shaped CLI for ~/.ai-md system skills/rules, templates/, and projects/",
3
+ "version": "0.4.0",
4
+ "description": "AXI-shaped CLI for ~/.ai-md with persisted dir/remote machine config, templates/, and projects/",
5
5
  "bin": {
6
6
  "ai-md": "bin/ai-md.js"
7
7
  },