@dujavi/ai-md 0.2.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
@@ -1,69 +1,61 @@
1
1
  # ai-md
2
2
 
3
- Public **AXI-shaped** CLI for a **private** personal AI config directory (`~/.ai-md`): global skills/rules, per-project templates, and agent tool bootstrap.
3
+ Public **AXI-shaped** CLI for a **private** personal AI config directory (`~/.ai-md`).
4
4
 
5
- | Layer | What | Where |
6
- |-------|------|--------|
7
- | **Public** | This package (`@dujavi/ai-md`) | npm + [github.com/dujavi/ai-md](https://github.com/dujavi/ai-md) |
8
- | **Private** | Your skills, rules, `projects/` | `~/.ai-md` git repo |
5
+ | Layer | Path | Purpose |
6
+ |-------|------|---------|
7
+ | **System** | `skills/`, `rules/` | Global base — linked to `~/.cursor/{skills,rules}` |
8
+ | **Templates** | `templates/<type>/` | Project-type starters (`base`, later `forms`, …) |
9
+ | **Projects** | `projects/<name>/` | Per-app overlays (repo `.cursor` → here) |
9
10
 
10
- No personal rules or skills ship in this package. Output defaults to [TOON](https://toonformat.dev/) with `help[]` next steps (`--json` available).
11
+ No personal content ships in this package. Reads default to TOON + `help[]` (`--json` available).
11
12
 
12
- ## Install
13
+ ## New machine
13
14
 
14
15
  ```bash
15
16
  npm i -g @dujavi/ai-md
16
- # binary: 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
 
19
- ## New machine
21
+ Or step by step:
20
22
 
21
23
  ```bash
22
- export AI_MD_REMOTE=https://github.com/<you>/.ai-md.git
24
+ ai-md config set --remote https://github.com/<you>/.ai-md.git --dir ~/.ai-md
23
25
  ai-md install
24
26
  ai-md ensure-tools
25
27
  ```
26
28
 
27
- Optional multi-agent skill links:
29
+ Precedence: `--remote`/`--dir` flags > `AI_MD_*` env > `~/.config/ai-md/config.json` > defaults.
28
30
 
29
- ```bash
30
- ai-md install --agents cursor,claude,agents
31
- ```
31
+ ## Layout idea
32
32
 
33
- ## Day-to-day
34
-
35
- ```bash
36
- ai-md # status (content-first)
37
- ai-md status --json
38
- ai-md pull
39
- ai-md push -m "why"
40
- ai-md doctor --fix
33
+ ```text
34
+ ~/.ai-md/
35
+ skills/ # system — all agents see these
36
+ rules/
37
+ templates/
38
+ base/ # default project starter (customizable stubs only)
39
+ # forms/ … # other use cases
40
+ projects/
41
+ presenter/
42
+ sendfolio/
41
43
  ```
42
44
 
43
- ## Projects (templating)
45
+ Put shared agentic skills/rules in **system** folders. Put only type-specific or per-project starters under **templates/**.
44
46
 
45
- ```bash
46
- # Seed from projects/template + link repo/.cursor + gitignore
47
- ai-md init-project --repo ~/my-app
47
+ ## Commands
48
48
 
49
- # Merge missing baseline files into an existing project
50
- ai-md apply-template --project my-app
51
-
52
- # Link only (empty rules/skills dirs if missing)
53
- ai-md link-project --repo ~/my-app
49
+ ```bash
50
+ ai-md # status
51
+ ai-md init-project --repo ~/app --from base
52
+ ai-md apply-template --project app --from base
53
+ ai-md doctor --fix --agents cursor,claude
54
+ ai-md pull | push -m "why"
55
+ ai-md ensure-tools
54
56
  ```
55
57
 
56
- ## AXI-shaped reads
57
-
58
- | Flag | Effect |
59
- |------|--------|
60
- | (default) | TOON on stdout + `help[]` |
61
- | `--json` | Normalized JSON |
62
- | `--full` | Paths + template drift details |
63
-
64
- Mutations (`push`, `ensure-tools`) stay human/git-oriented.
65
-
66
58
  ## Environment
67
59
 
68
- - `AI_MD_DIR` `~/.ai-md`
69
- - `AI_MD_REMOTE` `https://github.com/dujavi/.ai-md.git`
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
- from: "template",
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}`, {
@@ -117,54 +133,63 @@ function parseArgs(argv) {
117
133
  }
118
134
 
119
135
  function printHelp() {
120
- process.stdout.write(`ai-md — private ~/.ai-md skills/rules + project templates (AXI-shaped)
136
+ process.stdout.write(`ai-md — private ~/.ai-md: system skills/rules + templates/ + projects/ (AXI-shaped)
121
137
 
122
138
  Usage:
123
139
  ai-md [command] [options]
124
140
  npx -y @dujavi/ai-md [command] [options]
125
141
 
142
+ Layout:
143
+ ~/.ai-md/skills, rules System (global) base — linked to ~/.cursor
144
+ ~/.ai-md/templates/<type> Project-type starters (default: base)
145
+ ~/.ai-md/projects/<name> Per-app overlays (repo .cursor → here)
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
+
126
151
  Commands:
152
+ setup First-time machine setup: save config, install, optional --tools
153
+ config Show persisted config (or: config set --remote/--dir)
127
154
  status Snapshot (default when no command) [AXI]
128
155
  doctor Diagnose links/projects; --fix repairs
129
156
  install Clone remote if needed; link ~/.cursor + optional agents
130
157
  pull | push Sync private git repo
131
158
  ensure-tools Install/update grok + quota-axi (alias: tools)
132
- init-project Seed projects/<name> from template + link repo .cursor/
133
- apply-template Merge missing template files into an existing project
159
+ init-project Seed projects/<name> from templates/<from> + link .cursor/
160
+ apply-template Merge missing files from a template into a project
134
161
  link-project Link repo .cursor/ without seeding (alias: link)
135
162
  help Show this help
136
163
 
137
164
  Options:
138
- --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
139
168
  --full Include paths and drift details
140
169
  --agents <list> Skill link targets: cursor,claude,agents (default: cursor)
170
+ --tools With setup: also run ensure-tools
141
171
  --repo <path> App repository root
142
- --name <id> Project id under projects/ (default: basename)
172
+ --name <id> Project id under projects/
143
173
  --project <id> Target project for apply-template
144
- --from <id> Template folder (default: template)
145
- --force Replace non-symlink .cursor / re-merge
174
+ --from <id> Template under templates/ (default: base)
175
+ --force Replace non-symlink paths
146
176
  --dry-run Preview without writing
147
177
  --fix doctor: repair symlinks
148
178
  -m, --message push commit message
149
179
 
150
- Environment:
151
- AI_MD_DIR Private config dir (default: ~/.ai-md)
152
- AI_MD_REMOTE Clone URL (default: https://github.com/dujavi/.ai-md.git)
153
-
154
180
  Examples:
155
- ai-md
156
- ai-md status --json
157
- ai-md doctor --fix --agents cursor,claude
158
- ai-md init-project --repo ~/presenter
159
- ai-md apply-template --project presenter
160
- ai-md push -m "Add rule"
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
185
+ ai-md init-project --repo ~/presenter --from base
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,10 +237,122 @@ 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
- const data = collectStatus({ full: opts.full, agents: opts.agents });
351
+ const data = collectStatus({
352
+ full: opts.full,
353
+ agents: opts.agents,
354
+ from: opts.from,
355
+ });
188
356
  emit({ data, json: opts.json, help: statusHelp(data) });
189
357
  process.exitCode = data.counts.problems > 0 ? 1 : 0;
190
358
  break;
@@ -225,6 +393,7 @@ function main() {
225
393
  case "apply-template": {
226
394
  const data = applyTemplate({
227
395
  project: opts.project || opts.name,
396
+ from: opts.from,
228
397
  dryRun: opts.dryRun,
229
398
  });
230
399
  emit({
@@ -255,28 +424,18 @@ function main() {
255
424
  break;
256
425
  }
257
426
  case "install": {
258
- // clone/pull via bash, then Node links (incl. agents)
259
- const bashArgs = ["install"];
260
- if (opts.force) bashArgs.push("--force");
261
- if (opts.dryRun) bashArgs.push("--dry-run");
262
- const result = spawnSync(
263
- "bash",
264
- [path.join(scriptsDir, "sync-config.sh"), ...bashArgs],
265
- { stdio: "inherit", env: process.env }
266
- );
267
- if (result.status !== 0) {
268
- 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);
269
434
  }
270
- const links = [
271
- ...ensureCursorLinks(cfg, { force: opts.force, dryRun: opts.dryRun }),
272
- ...ensureAgentSkillLinks(cfg, opts.agents, {
273
- force: opts.force,
274
- dryRun: opts.dryRun,
275
- }),
276
- ];
435
+ const links = runInstall(cfg, opts);
277
436
  const data = collectStatus({ full: opts.full, agents: opts.agents });
278
437
  emit({
279
- data: { install: "ok", links, ...data },
438
+ data: { install: "ok", config: saved, links, ...data },
280
439
  json: opts.json,
281
440
  help: [
282
441
  "Run `ai-md ensure-tools` to install grok + quota-axi",
@@ -286,23 +445,26 @@ function main() {
286
445
  break;
287
446
  }
288
447
  case "pull":
289
- runBash("sync-config.sh", [
290
- "pull",
291
- ...(opts.dryRun ? ["--dry-run"] : []),
292
- ]);
448
+ runBash(
449
+ "sync-config.sh",
450
+ ["pull", ...(opts.dryRun ? ["--dry-run"] : [])],
451
+ process.env
452
+ );
293
453
  break;
294
454
  case "push": {
295
455
  const args = ["push"];
296
456
  if (opts.message) args.push("-m", opts.message);
297
457
  if (opts.dryRun) args.push("--dry-run");
298
- runBash("sync-config.sh", args);
458
+ runBash("sync-config.sh", args, process.env);
299
459
  break;
300
460
  }
301
461
  case "ensure-tools":
302
462
  case "tools":
303
- runBash("ensure-agent-tools.sh", [
304
- ...(opts.dryRun ? ["--dry-run"] : []),
305
- ]);
463
+ runBash(
464
+ "ensure-agent-tools.sh",
465
+ [...(opts.dryRun ? ["--dry-run"] : [])],
466
+ process.env
467
+ );
306
468
  break;
307
469
  default:
308
470
  fail(`unknown command: ${opts.cmd}`, {
package/lib/commands.js CHANGED
@@ -4,6 +4,7 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const {
6
6
  resolveConfig,
7
+ templatePath,
7
8
  symlinkState,
8
9
  agentSkillTargets,
9
10
  } = require("./config");
@@ -86,7 +87,7 @@ function copyDir(src, dest, { dryRun = false } = {}) {
86
87
  function initProject({
87
88
  repo,
88
89
  name,
89
- from = "template",
90
+ from = "base",
90
91
  force = false,
91
92
  dryRun = false,
92
93
  } = {}) {
@@ -103,11 +104,13 @@ function initProject({
103
104
  }
104
105
  const absRepo = fs.realpathSync(repo);
105
106
  const projectName = name || path.basename(absRepo);
106
- const templateDir = path.join(cfg.projectsDir, from);
107
+ const tmplDir = templatePath(cfg, from);
107
108
  const projectDir = path.join(cfg.projectsDir, projectName);
108
109
 
109
- if (!fs.existsSync(templateDir)) {
110
- const err = new Error(`template not found: ${templateDir}`);
110
+ if (!fs.existsSync(tmplDir)) {
111
+ const err = new Error(
112
+ `template not found: ${tmplDir} (available under ${cfg.templatesDir}/)`
113
+ );
111
114
  err.code = "ENOENT";
112
115
  throw err;
113
116
  }
@@ -121,11 +124,10 @@ function initProject({
121
124
  if (hasContent && !force) {
122
125
  actions.push({ action: "exists", path: projectDir });
123
126
  } else {
124
- // Empty project dir, or --force: merge missing baseline files only
125
- actions.push(...mergeTemplate(templateDir, projectDir, { dryRun }));
127
+ actions.push(...mergeTemplate(tmplDir, projectDir, { dryRun }));
126
128
  }
127
129
  } else {
128
- actions.push(copyDir(templateDir, projectDir, { dryRun }));
130
+ actions.push(copyDir(tmplDir, projectDir, { dryRun }));
129
131
  }
130
132
 
131
133
  const link = path.join(absRepo, ".cursor");
@@ -137,6 +139,7 @@ function initProject({
137
139
  projectDir,
138
140
  repo: absRepo,
139
141
  from,
142
+ templateDir: tmplDir,
140
143
  actions,
141
144
  };
142
145
  }
@@ -172,7 +175,7 @@ function mergeTemplate(templateDir, projectDir, { dryRun = false } = {}) {
172
175
  return actions;
173
176
  }
174
177
 
175
- function applyTemplate({ project, dryRun = false } = {}) {
178
+ function applyTemplate({ project, from = "base", dryRun = false } = {}) {
176
179
  const cfg = resolveConfig();
177
180
  if (!project) {
178
181
  const err = new Error("missing --project <name>");
@@ -185,13 +188,14 @@ function applyTemplate({ project, dryRun = false } = {}) {
185
188
  err.code = "ENOENT";
186
189
  throw err;
187
190
  }
188
- if (project === "template") {
189
- const err = new Error("cannot apply-template onto template itself");
190
- err.code = "EINVAL";
191
+ const tmplDir = templatePath(cfg, from);
192
+ if (!fs.existsSync(tmplDir)) {
193
+ const err = new Error(`template not found: ${tmplDir}`);
194
+ err.code = "ENOENT";
191
195
  throw err;
192
196
  }
193
- const actions = mergeTemplate(cfg.templateDir, projectDir, { dryRun });
194
- return { project, projectDir, actions };
197
+ const actions = mergeTemplate(tmplDir, projectDir, { dryRun });
198
+ return { project, projectDir, from, templateDir: tmplDir, actions };
195
199
  }
196
200
 
197
201
  function linkProject({ repo, name, force = false, dryRun = false } = {}) {
package/lib/config.js CHANGED
@@ -4,27 +4,140 @@ 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
+
103
+ const templatesDir = path.join(dir, "templates");
15
104
  return {
16
105
  home,
17
106
  dir,
18
107
  remote,
108
+ sources,
109
+ machineConfigPath: stored.path,
110
+ machineConfig: stored.raw,
19
111
  cursorSkills: path.join(home, ".cursor", "skills"),
20
112
  cursorRules: path.join(home, ".cursor", "rules"),
21
113
  projectsDir: path.join(dir, "projects"),
22
- templateDir: path.join(dir, "projects", "template"),
114
+ templatesDir,
115
+ templateDir: path.join(templatesDir, "base"),
23
116
  skillsDir: path.join(dir, "skills"),
24
117
  rulesDir: path.join(dir, "rules"),
25
118
  };
26
119
  }
27
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
+
128
+ function templatePath(cfg, name = "base") {
129
+ return path.join(cfg.templatesDir, name);
130
+ }
131
+
132
+ function listTemplates(cfg) {
133
+ if (!fs.existsSync(cfg.templatesDir)) return [];
134
+ return fs
135
+ .readdirSync(cfg.templatesDir, { withFileTypes: true })
136
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
137
+ .map((d) => d.name)
138
+ .sort();
139
+ }
140
+
28
141
  function countRules(rulesDir) {
29
142
  if (!fs.existsSync(rulesDir)) return 0;
30
143
  return fs
@@ -88,8 +201,29 @@ function agentSkillTargets(home, agents) {
88
201
  .map((name) => ({ name, path: map[name] }));
89
202
  }
90
203
 
204
+ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
205
+ const legacy = path.join(cfg.projectsDir, "template");
206
+ const dest = templatePath(cfg, "base");
207
+ if (!fs.existsSync(legacy) || fs.existsSync(dest)) {
208
+ return { action: "skip" };
209
+ }
210
+ if (dryRun) return { action: "would_migrate", from: legacy, to: dest };
211
+ fs.mkdirSync(cfg.templatesDir, { recursive: true });
212
+ fs.renameSync(legacy, dest);
213
+ return { action: "migrated", from: legacy, to: dest };
214
+ }
215
+
91
216
  module.exports = {
217
+ DEFAULT_REMOTE,
218
+ machineConfigPath,
219
+ readMachineConfig,
220
+ writeMachineConfig,
92
221
  resolveConfig,
222
+ applyEnvFromConfig,
223
+ expandHome,
224
+ templatePath,
225
+ listTemplates,
226
+ migrateLegacyTemplate,
93
227
  countRules,
94
228
  countSkills,
95
229
  listSkillNames,
package/lib/status.js CHANGED
@@ -5,6 +5,9 @@ const path = require("path");
5
5
  const { execFileSync } = require("child_process");
6
6
  const {
7
7
  resolveConfig,
8
+ templatePath,
9
+ listTemplates,
10
+ migrateLegacyTemplate,
8
11
  countRules,
9
12
  countSkills,
10
13
  listSkillNames,
@@ -19,17 +22,17 @@ function git(repo, args) {
19
22
  encoding: "utf8",
20
23
  stdio: ["ignore", "pipe", "pipe"],
21
24
  }).trim();
22
- } catch (err) {
25
+ } catch {
23
26
  return null;
24
27
  }
25
28
  }
26
29
 
27
- function templateDrift(projectDir, templateDir) {
28
- if (!fs.existsSync(templateDir)) {
30
+ function templateDrift(projectDir, tmplDir) {
31
+ if (!fs.existsSync(tmplDir)) {
29
32
  return { missingRules: [], missingSkills: [], extraRules: [], extraSkills: [] };
30
33
  }
31
- const tRules = new Set(listRuleNames(path.join(templateDir, "rules")));
32
- const tSkills = new Set(listSkillNames(path.join(templateDir, "skills")));
34
+ const tRules = new Set(listRuleNames(path.join(tmplDir, "rules")));
35
+ const tSkills = new Set(listSkillNames(path.join(tmplDir, "skills")));
33
36
  const pRules = new Set(listRuleNames(path.join(projectDir, "rules")));
34
37
  const pSkills = new Set(listSkillNames(path.join(projectDir, "skills")));
35
38
  return {
@@ -40,44 +43,38 @@ function templateDrift(projectDir, templateDir) {
40
43
  };
41
44
  }
42
45
 
43
- function collectProjects(cfg, { full = false } = {}) {
46
+ function collectTemplates(cfg, { full = false } = {}) {
47
+ return listTemplates(cfg).map((name) => {
48
+ const dir = templatePath(cfg, name);
49
+ return {
50
+ name,
51
+ rules: countRules(path.join(dir, "rules")),
52
+ skills: countSkills(path.join(dir, "skills")),
53
+ ...(full ? { path: dir } : {}),
54
+ };
55
+ });
56
+ }
57
+
58
+ function collectProjects(cfg, { full = false, from = "base" } = {}) {
44
59
  const projects = [];
60
+ const tmplDir = templatePath(cfg, from);
45
61
  if (!fs.existsSync(cfg.projectsDir)) return projects;
46
62
  for (const name of fs.readdirSync(cfg.projectsDir).sort()) {
47
63
  if (name.startsWith(".")) continue;
64
+ // legacy leftover
65
+ if (name === "template") continue;
48
66
  const projectDir = path.join(cfg.projectsDir, name);
49
67
  if (!fs.statSync(projectDir).isDirectory()) continue;
50
- if (name === "template") {
51
- projects.push({
52
- name,
53
- kind: "template",
54
- rules: countRules(path.join(projectDir, "rules")),
55
- skills: countSkills(path.join(projectDir, "skills")),
56
- missingRules: 0,
57
- missingSkills: 0,
58
- ...(full
59
- ? {
60
- path: projectDir,
61
- drift: templateDrift(projectDir, cfg.templateDir),
62
- }
63
- : {}),
64
- });
65
- continue;
66
- }
67
- const drift = templateDrift(projectDir, cfg.templateDir);
68
+ const drift = templateDrift(projectDir, tmplDir);
68
69
  projects.push({
69
70
  name,
70
71
  kind: "project",
72
+ from,
71
73
  rules: countRules(path.join(projectDir, "rules")),
72
74
  skills: countSkills(path.join(projectDir, "skills")),
73
75
  missingRules: drift.missingRules.length,
74
76
  missingSkills: drift.missingSkills.length,
75
- ...(full
76
- ? {
77
- path: projectDir,
78
- drift,
79
- }
80
- : {}),
77
+ ...(full ? { path: projectDir, drift } : {}),
81
78
  });
82
79
  }
83
80
  return projects;
@@ -87,6 +84,9 @@ function collectStatus(opts = {}) {
87
84
  const cfg = resolveConfig();
88
85
  const full = Boolean(opts.full);
89
86
  const agents = opts.agents || ["cursor"];
87
+ const from = opts.from || "base";
88
+
89
+ const migration = migrateLegacyTemplate(cfg);
90
90
 
91
91
  const exists = fs.existsSync(cfg.dir);
92
92
  const isGit = exists && fs.existsSync(path.join(cfg.dir, ".git"));
@@ -103,9 +103,9 @@ function collectStatus(opts = {}) {
103
103
  ...symlinkState(t.path, cfg.skillsDir),
104
104
  }));
105
105
 
106
- const projects = collectProjects(cfg, { full });
107
- const linkedProjects = projects.filter((p) => p.kind === "project");
108
- const drifting = linkedProjects.filter(
106
+ const templates = collectTemplates(cfg, { full });
107
+ const projects = collectProjects(cfg, { full, from });
108
+ const drifting = projects.filter(
109
109
  (p) => p.missingRules > 0 || p.missingSkills > 0
110
110
  );
111
111
 
@@ -127,18 +127,31 @@ function collectStatus(opts = {}) {
127
127
  for (const l of links) {
128
128
  if (l.state !== "ok") problems.push(`cursor_${path.basename(l.path)}_${l.state}`);
129
129
  }
130
+ if (!fs.existsSync(templatePath(cfg, "base")) && templates.length === 0) {
131
+ problems.push("templates_missing");
132
+ }
130
133
 
131
134
  return {
132
135
  generatedAt: new Date().toISOString(),
133
136
  dir: cfg.dir,
134
137
  remote: remote || cfg.remote,
138
+ sources: cfg.sources,
139
+ machineConfigPath: cfg.machineConfigPath,
140
+ machineConfig: cfg.machineConfig,
135
141
  branch,
136
142
  dirty,
137
143
  statusLine: aheadBehind,
144
+ layout: {
145
+ system: { rules: cfg.rulesDir, skills: cfg.skillsDir },
146
+ templates: cfg.templatesDir,
147
+ projects: cfg.projectsDir,
148
+ },
149
+ migration,
138
150
  counts: {
139
151
  rules: countRules(cfg.rulesDir),
140
152
  skills: countSkills(cfg.skillsDir),
141
- projects: linkedProjects.length,
153
+ templates: templates.length,
154
+ projects: projects.length,
142
155
  drifting: drifting.length,
143
156
  problems: problems.length,
144
157
  },
@@ -153,18 +166,9 @@ function collectStatus(opts = {}) {
153
166
  state: l.state,
154
167
  ...(full ? { target: l.target, expected: l.expected } : {}),
155
168
  })),
156
- projects: projects.map((p) =>
157
- full
158
- ? p
159
- : {
160
- name: p.name,
161
- kind: p.kind,
162
- rules: p.rules,
163
- skills: p.skills,
164
- missingRules: p.missingRules,
165
- missingSkills: p.missingSkills,
166
- }
167
- ),
169
+ templates,
170
+ projects,
171
+ driftFrom: from,
168
172
  problems,
169
173
  };
170
174
  }
@@ -172,22 +176,32 @@ function collectStatus(opts = {}) {
172
176
  function statusHelp(data) {
173
177
  const help = [];
174
178
  if (data.problems.includes("ai_md_missing") || data.problems.includes("ai_md_not_git")) {
175
- 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
+ );
176
187
  }
177
188
  if (data.links.some((l) => l.state !== "ok")) {
178
189
  help.push("Run `ai-md doctor --fix` to repair ~/.cursor skills/rules symlinks");
179
190
  }
191
+ if (data.problems.includes("templates_missing")) {
192
+ help.push("Create ~/.ai-md/templates/base with project starters (system skills/rules stay in skills/ and rules/)");
193
+ }
180
194
  if (data.counts.drifting > 0) {
181
195
  help.push(
182
- "Run `ai-md apply-template --project <name>` to merge missing baseline files from projects/template"
196
+ `Run \`ai-md apply-template --project <name> --from ${data.driftFrom || "base"}\` to merge missing template files`
183
197
  );
184
198
  }
185
199
  if (data.dirty) {
186
200
  help.push('Run `ai-md push -m "<why>"` to commit and push private config');
187
201
  } else {
188
- help.push("Run `ai-md pull` before editing global skills/rules");
202
+ help.push("Edit system skills/rules under ~/.ai-md/{skills,rules}; project overlays under projects/");
189
203
  }
190
- help.push("Run `ai-md init-project --repo <path>` to seed a project from template and link .cursor/");
204
+ help.push("Run `ai-md init-project --repo <path> --from base` to seed a project");
191
205
  help.push("Run `ai-md status --json` for machine-readable output");
192
206
  return help;
193
207
  }
@@ -195,6 +209,7 @@ function statusHelp(data) {
195
209
  module.exports = {
196
210
  collectStatus,
197
211
  collectProjects,
212
+ collectTemplates,
198
213
  templateDrift,
199
214
  statusHelp,
200
215
  git,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dujavi/ai-md",
3
- "version": "0.2.0",
4
- "description": "AXI-shaped CLI for private ~/.ai-md skills/rules, project templates, and agent tool bootstrap",
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
  },