@dujavi/ai-md 0.1.0 → 0.2.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,53 +1,69 @@
1
1
  # ai-md
2
2
 
3
- Public CLI for syncing a **private** personal AI config directory (`~/.ai-md`) and installing agent CLIs.
3
+ Public **AXI-shaped** CLI for a **private** personal AI config directory (`~/.ai-md`): global skills/rules, per-project templates, and agent tool bootstrap.
4
4
 
5
5
  | Layer | What | Where |
6
6
  |-------|------|--------|
7
- | **Public** | This package (`ai-md`) — sync/link/ensure-tools scripts only | npm + [github.com/dujavi/ai-md](https://github.com/dujavi/ai-md) |
8
- | **Private** | Your skills, rules, projects | `~/.ai-md` git repo (e.g. `github.com/<you>/.ai-md`) |
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 |
9
9
 
10
- No personal rules or skills ship in this package.
10
+ No personal rules or skills ship in this package. Output defaults to [TOON](https://toonformat.dev/) with `help[]` next steps (`--json` available).
11
11
 
12
12
  ## Install
13
13
 
14
14
  ```bash
15
15
  npm i -g @dujavi/ai-md
16
- # or one-shot:
17
- npx @dujavi/ai-md --help
16
+ # binary: ai-md
18
17
  ```
19
18
 
20
19
  ## New machine
21
20
 
22
21
  ```bash
23
- npm i -g @dujavi/ai-md
24
- export AI_MD_REMOTE=https://github.com/<you>/.ai-md.git # your private content repo
22
+ export AI_MD_REMOTE=https://github.com/<you>/.ai-md.git
25
23
  ai-md install
26
- ai-md ensure-tools # grok + quota-axi
24
+ ai-md ensure-tools
27
25
  ```
28
26
 
29
- Defaults (override with env):
27
+ Optional multi-agent skill links:
30
28
 
31
- - `AI_MD_DIR` → `~/.ai-md`
32
- - `AI_MD_REMOTE` `https://github.com/dujavi/.ai-md.git`
29
+ ```bash
30
+ ai-md install --agents cursor,claude,agents
31
+ ```
33
32
 
34
- ## Commands
33
+ ## Day-to-day
35
34
 
36
35
  ```bash
37
- ai-md install
36
+ ai-md # status (content-first)
37
+ ai-md status --json
38
38
  ai-md pull
39
39
  ai-md push -m "why"
40
- ai-md status
41
40
  ai-md doctor --fix
42
- ai-md ensure-tools # alias: tools
41
+ ```
42
+
43
+ ## Projects (templating)
44
+
45
+ ```bash
46
+ # Seed from projects/template + link repo/.cursor + gitignore
47
+ ai-md init-project --repo ~/my-app
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)
43
53
  ai-md link-project --repo ~/my-app
44
54
  ```
45
55
 
46
- `install` / `pull` / `doctor` keep:
56
+ ## AXI-shaped reads
47
57
 
48
- - `~/.cursor/skills` `$AI_MD_DIR/skills`
49
- - `~/.cursor/rules` → `$AI_MD_DIR/rules`
58
+ | Flag | Effect |
59
+ |------|--------|
60
+ | (default) | TOON on stdout + `help[]` |
61
+ | `--json` | Normalized JSON |
62
+ | `--full` | Paths + template drift details |
50
63
 
51
- ## Agent-friendly
64
+ Mutations (`push`, `ensure-tools`) stay human/git-oriented.
52
65
 
53
- Non-interactive flags only (`--dry-run`, `--force`, `--fix`, `-m`). No prompts. Prefer `npx -y @dujavi/ai-md <cmd>` when not installed globally.
66
+ ## Environment
67
+
68
+ - `AI_MD_DIR` → `~/.ai-md`
69
+ - `AI_MD_REMOTE` → `https://github.com/dujavi/.ai-md.git`
package/bin/ai-md.js CHANGED
@@ -2,98 +2,324 @@
2
2
  "use strict";
3
3
 
4
4
  const { spawnSync } = require("child_process");
5
- const os = require("os");
6
5
  const path = require("path");
6
+ const { resolveConfig } = require("../lib/config");
7
+ const { emit, fail } = require("../lib/output");
8
+ const { collectStatus, statusHelp } = require("../lib/status");
9
+ const {
10
+ initProject,
11
+ applyTemplate,
12
+ linkProject,
13
+ runDoctor,
14
+ ensureCursorLinks,
15
+ ensureAgentSkillLinks,
16
+ } = require("../lib/commands");
7
17
 
8
18
  const scriptsDir = path.join(__dirname, "..", "scripts");
9
- const home = os.homedir();
19
+ const cfg = resolveConfig();
10
20
 
11
- const env = {
12
- ...process.env,
13
- AI_MD_DIR:
14
- process.env.AI_MD_DIR ||
15
- process.env.CURSOR_MD_DIR ||
16
- path.join(home, ".ai-md"),
17
- AI_MD_REMOTE:
18
- process.env.AI_MD_REMOTE ||
19
- process.env.CURSOR_MD_REMOTE ||
20
- "https://github.com/dujavi/.ai-md.git",
21
- };
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;
22
25
 
23
- // Keep legacy env names in sync for older wrappers.
24
- env.CURSOR_MD_DIR = env.AI_MD_DIR;
25
- env.CURSOR_MD_REMOTE = env.AI_MD_REMOTE;
26
-
27
- const COMMANDS = {
28
- install: { script: "sync-config.sh", prefix: ["install"] },
29
- pull: { script: "sync-config.sh", prefix: ["pull"] },
30
- push: { script: "sync-config.sh", prefix: ["push"] },
31
- status: { script: "sync-config.sh", prefix: ["status"] },
32
- doctor: { script: "sync-config.sh", prefix: ["doctor"] },
33
- "ensure-tools": { script: "ensure-agent-tools.sh", prefix: [] },
34
- tools: { script: "ensure-agent-tools.sh", prefix: [] },
35
- "link-project": { script: "link-project.sh", prefix: [] },
36
- link: { script: "link-project.sh", prefix: [] },
37
- };
26
+ function parseArgs(argv) {
27
+ const out = {
28
+ cmd: null,
29
+ json: false,
30
+ full: false,
31
+ force: false,
32
+ dryRun: false,
33
+ fix: false,
34
+ message: null,
35
+ repo: null,
36
+ name: null,
37
+ project: null,
38
+ from: "template",
39
+ agents: ["cursor"],
40
+ rest: [],
41
+ };
42
+ const args = [...argv];
43
+ if (args.length === 0) {
44
+ out.cmd = "status";
45
+ return out;
46
+ }
47
+ const first = args[0];
48
+ if (first === "-h" || first === "--help" || first === "help") {
49
+ out.cmd = "help";
50
+ return out;
51
+ }
52
+ if (first.startsWith("-")) {
53
+ // flags before command → treat as status with flags
54
+ out.cmd = "status";
55
+ } else {
56
+ out.cmd = args.shift();
57
+ }
58
+ while (args.length) {
59
+ const a = args.shift();
60
+ switch (a) {
61
+ case "--json":
62
+ out.json = true;
63
+ break;
64
+ case "--full":
65
+ out.full = true;
66
+ break;
67
+ case "--force":
68
+ out.force = true;
69
+ break;
70
+ case "--dry-run":
71
+ out.dryRun = true;
72
+ break;
73
+ case "--fix":
74
+ out.fix = true;
75
+ out.force = true;
76
+ break;
77
+ case "-m":
78
+ case "--message":
79
+ out.message = args.shift();
80
+ break;
81
+ case "--repo":
82
+ out.repo = args.shift();
83
+ break;
84
+ case "--name":
85
+ out.name = args.shift();
86
+ break;
87
+ case "--project":
88
+ out.project = args.shift();
89
+ break;
90
+ case "--from":
91
+ out.from = args.shift();
92
+ break;
93
+ case "--agents":
94
+ out.agents = String(args.shift() || "cursor")
95
+ .split(",")
96
+ .map((s) => s.trim())
97
+ .filter(Boolean);
98
+ break;
99
+ case "-h":
100
+ case "--help":
101
+ out.cmdHelp = true;
102
+ break;
103
+ default:
104
+ if (a.startsWith("-")) {
105
+ fail(`unknown flag: ${a}`, {
106
+ exitCode: 2,
107
+ json: out.json,
108
+ help: ["Run `ai-md --help`"],
109
+ });
110
+ process.exit(2);
111
+ }
112
+ out.rest.push(a);
113
+ break;
114
+ }
115
+ }
116
+ return out;
117
+ }
38
118
 
39
119
  function printHelp() {
40
- console.log(`ai-md — sync private ~/.ai-md skills/rules; install agent CLIs
120
+ process.stdout.write(`ai-md — private ~/.ai-md skills/rules + project templates (AXI-shaped)
41
121
 
42
122
  Usage:
43
- ai-md <command> [options]
44
- npx @dujavi/ai-md <command> [options]
123
+ ai-md [command] [options]
124
+ npx -y @dujavi/ai-md [command] [options]
45
125
 
46
126
  Commands:
47
- install Clone AI_MD_REMOTE ~/.ai-md (if needed); link ~/.cursor/{skills,rules}
48
- pull git pull private config; refresh symlinks
49
- push commit + push private config (-m/--message)
50
- status repo + symlink health
51
- doctor diagnose; --fix repairs symlinks
52
- ensure-tools install/update grok + quota-axi (alias: tools)
53
- link-project link a repo .cursor/ ~/.ai-md/projects/<name>/
54
- help show this help
127
+ status Snapshot (default when no command) [AXI]
128
+ doctor Diagnose links/projects; --fix repairs
129
+ install Clone remote if needed; link ~/.cursor + optional agents
130
+ pull | push Sync private git repo
131
+ 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
134
+ link-project Link repo .cursor/ without seeding (alias: link)
135
+ help Show this help
136
+
137
+ Options:
138
+ --json JSON instead of TOON (status/doctor/init/…)
139
+ --full Include paths and drift details
140
+ --agents <list> Skill link targets: cursor,claude,agents (default: cursor)
141
+ --repo <path> App repository root
142
+ --name <id> Project id under projects/ (default: basename)
143
+ --project <id> Target project for apply-template
144
+ --from <id> Template folder (default: template)
145
+ --force Replace non-symlink .cursor / re-merge
146
+ --dry-run Preview without writing
147
+ --fix doctor: repair symlinks
148
+ -m, --message push commit message
55
149
 
56
150
  Environment:
57
- AI_MD_DIR Private config dir (default: ~/.ai-md)
58
- AI_MD_REMOTE Git remote if clone needed (default: https://github.com/dujavi/.ai-md.git)
151
+ AI_MD_DIR Private config dir (default: ~/.ai-md)
152
+ AI_MD_REMOTE Clone URL (default: https://github.com/dujavi/.ai-md.git)
59
153
 
60
154
  Examples:
61
- npm i -g @dujavi/ai-md
62
- ai-md install
63
- ai-md ensure-tools
64
- ai-md pull
65
- ai-md push -m "Add routing rule"
66
- ai-md link-project --repo ~/presenter
67
- ai-md doctor --fix
68
-
69
- Private skills/rules live in each person's AI_MD_DIR repo.
70
- This package ships tooling only — no personal content.
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"
71
161
  `);
72
162
  }
73
163
 
74
- const [cmd, ...args] = process.argv.slice(2);
75
-
76
- if (!cmd || cmd === "help" || cmd === "-h" || cmd === "--help") {
77
- printHelp();
78
- process.exit(0);
164
+ function runBash(script, args) {
165
+ const result = spawnSync("bash", [path.join(scriptsDir, script), ...args], {
166
+ stdio: "inherit",
167
+ env: process.env,
168
+ });
169
+ if (result.error) {
170
+ fail(result.error.message, { exitCode: 1 });
171
+ process.exit(1);
172
+ }
173
+ process.exit(result.status === null ? 1 : result.status);
79
174
  }
80
175
 
81
- const mapped = COMMANDS[cmd];
82
- if (!mapped) {
83
- console.error(`error: unknown command: ${cmd}`);
84
- console.error(` ai-md --help`);
85
- process.exit(2);
86
- }
176
+ function main() {
177
+ const opts = parseArgs(process.argv.slice(2));
87
178
 
88
- const scriptPath = path.join(scriptsDir, mapped.script);
89
- const result = spawnSync("bash", [scriptPath, ...mapped.prefix, ...args], {
90
- stdio: "inherit",
91
- env,
92
- });
179
+ if (opts.cmd === "help" || opts.cmdHelp) {
180
+ printHelp();
181
+ return;
182
+ }
93
183
 
94
- if (result.error) {
95
- console.error(`error: failed to run ${mapped.script}: ${result.error.message}`);
96
- process.exit(1);
184
+ try {
185
+ switch (opts.cmd) {
186
+ case "status": {
187
+ const data = collectStatus({ full: opts.full, agents: opts.agents });
188
+ emit({ data, json: opts.json, help: statusHelp(data) });
189
+ process.exitCode = data.counts.problems > 0 ? 1 : 0;
190
+ break;
191
+ }
192
+ case "doctor": {
193
+ const data = runDoctor({
194
+ fix: opts.fix,
195
+ force: opts.force,
196
+ agents: opts.agents,
197
+ dryRun: opts.dryRun,
198
+ });
199
+ emit({
200
+ data,
201
+ json: opts.json,
202
+ help: data.help,
203
+ });
204
+ process.exitCode = data.after.problems.length > 0 ? 1 : 0;
205
+ break;
206
+ }
207
+ case "init-project": {
208
+ const data = initProject({
209
+ repo: opts.repo,
210
+ name: opts.name,
211
+ from: opts.from,
212
+ force: opts.force,
213
+ dryRun: opts.dryRun,
214
+ });
215
+ emit({
216
+ data,
217
+ json: opts.json,
218
+ help: [
219
+ "Customize rules/agentic-workflow.mdc for this project",
220
+ 'Run `ai-md push -m "Init <project>"` after reviewing the private repo diff',
221
+ ],
222
+ });
223
+ break;
224
+ }
225
+ case "apply-template": {
226
+ const data = applyTemplate({
227
+ project: opts.project || opts.name,
228
+ dryRun: opts.dryRun,
229
+ });
230
+ emit({
231
+ data,
232
+ json: opts.json,
233
+ help: [
234
+ "Review added files under ~/.ai-md/projects/<name>/",
235
+ 'Run `ai-md push -m "Apply template to <project>"` when ready',
236
+ ],
237
+ });
238
+ break;
239
+ }
240
+ case "link-project":
241
+ case "link": {
242
+ const data = linkProject({
243
+ repo: opts.repo,
244
+ name: opts.name,
245
+ force: opts.force,
246
+ dryRun: opts.dryRun,
247
+ });
248
+ emit({
249
+ data,
250
+ json: opts.json,
251
+ help: [
252
+ "Prefer `ai-md init-project --repo <path>` to seed from template first",
253
+ ],
254
+ });
255
+ break;
256
+ }
257
+ 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);
269
+ }
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
+ ];
277
+ const data = collectStatus({ full: opts.full, agents: opts.agents });
278
+ emit({
279
+ data: { install: "ok", links, ...data },
280
+ json: opts.json,
281
+ help: [
282
+ "Run `ai-md ensure-tools` to install grok + quota-axi",
283
+ "Run `ai-md init-project --repo <path>` for a new app",
284
+ ],
285
+ });
286
+ break;
287
+ }
288
+ case "pull":
289
+ runBash("sync-config.sh", [
290
+ "pull",
291
+ ...(opts.dryRun ? ["--dry-run"] : []),
292
+ ]);
293
+ break;
294
+ case "push": {
295
+ const args = ["push"];
296
+ if (opts.message) args.push("-m", opts.message);
297
+ if (opts.dryRun) args.push("--dry-run");
298
+ runBash("sync-config.sh", args);
299
+ break;
300
+ }
301
+ case "ensure-tools":
302
+ case "tools":
303
+ runBash("ensure-agent-tools.sh", [
304
+ ...(opts.dryRun ? ["--dry-run"] : []),
305
+ ]);
306
+ break;
307
+ default:
308
+ fail(`unknown command: ${opts.cmd}`, {
309
+ exitCode: 2,
310
+ json: opts.json,
311
+ help: ["Run `ai-md --help`"],
312
+ });
313
+ process.exit(2);
314
+ }
315
+ } catch (err) {
316
+ fail(err.message || String(err), {
317
+ exitCode: err.code === "EINVAL" ? 2 : 1,
318
+ json: opts.json,
319
+ help: ["Run `ai-md --help`"],
320
+ });
321
+ process.exit(process.exitCode || 1);
322
+ }
97
323
  }
98
324
 
99
- process.exit(result.status === null ? 1 : result.status);
325
+ main();
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const {
6
+ resolveConfig,
7
+ symlinkState,
8
+ agentSkillTargets,
9
+ } = require("./config");
10
+ const { collectStatus, statusHelp, collectProjects } = require("./status");
11
+
12
+ function ensureDir(p) {
13
+ fs.mkdirSync(p, { recursive: true });
14
+ }
15
+
16
+ function linkPath(target, link, { force = false, dryRun = false } = {}) {
17
+ ensureDir(path.dirname(link));
18
+ ensureDir(target);
19
+ const state = symlinkState(link, target);
20
+ if (state.state === "ok") {
21
+ return { path: link, action: "ok", target };
22
+ }
23
+ if (state.state === "not_symlink" && !force) {
24
+ const err = new Error(
25
+ `${link} exists and is not a symlink (use --force to replace)`
26
+ );
27
+ err.code = "EEXIST";
28
+ throw err;
29
+ }
30
+ if (dryRun) {
31
+ return { path: link, action: state.state === "missing" ? "would_link" : "would_repair", target };
32
+ }
33
+ if (fs.existsSync(link) || state.state !== "missing") {
34
+ try {
35
+ fs.lstatSync(link);
36
+ fs.rmSync(link, { recursive: true, force: true });
37
+ } catch {
38
+ /* missing */
39
+ }
40
+ }
41
+ fs.symlinkSync(target, link);
42
+ return {
43
+ path: link,
44
+ action: state.state === "missing" ? "linked" : "repaired",
45
+ target,
46
+ };
47
+ }
48
+
49
+ function ensureCursorLinks(cfg, opts = {}) {
50
+ return [
51
+ linkPath(cfg.skillsDir, cfg.cursorSkills, opts),
52
+ linkPath(cfg.rulesDir, cfg.cursorRules, opts),
53
+ ];
54
+ }
55
+
56
+ function ensureAgentSkillLinks(cfg, agents, opts = {}) {
57
+ return agentSkillTargets(cfg.home, agents)
58
+ .filter((t) => t.name !== "cursor") // cursor handled via ensureCursorLinks
59
+ .map((t) => linkPath(cfg.skillsDir, t.path, opts));
60
+ }
61
+
62
+ function ensureGitignore(repoPath, { dryRun = false } = {}) {
63
+ const gitignore = path.join(repoPath, ".gitignore");
64
+ if (!fs.existsSync(gitignore)) {
65
+ return { path: gitignore, action: "missing_gitignore" };
66
+ }
67
+ const text = fs.readFileSync(gitignore, "utf8");
68
+ const lines = text.split(/\r?\n/);
69
+ if (lines.some((l) => l.trim() === ".cursor/")) {
70
+ return { path: gitignore, action: "ok" };
71
+ }
72
+ if (dryRun) return { path: gitignore, action: "would_append" };
73
+ const suffix =
74
+ (text.endsWith("\n") || text.length === 0 ? "" : "\n") +
75
+ "\n# Personal Cursor config (symlink to ~/.ai-md)\n.cursor/\n";
76
+ fs.appendFileSync(gitignore, suffix);
77
+ return { path: gitignore, action: "appended" };
78
+ }
79
+
80
+ function copyDir(src, dest, { dryRun = false } = {}) {
81
+ if (dryRun) return { action: "would_copy", from: src, to: dest };
82
+ fs.cpSync(src, dest, { recursive: true, force: false, errorOnExist: false });
83
+ return { action: "copied", from: src, to: dest };
84
+ }
85
+
86
+ function initProject({
87
+ repo,
88
+ name,
89
+ from = "template",
90
+ force = false,
91
+ dryRun = false,
92
+ } = {}) {
93
+ const cfg = resolveConfig();
94
+ if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
95
+ const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
96
+ err.code = "ENOENT";
97
+ throw err;
98
+ }
99
+ if (!repo || !fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) {
100
+ const err = new Error(`missing or invalid --repo <path>`);
101
+ err.code = "EINVAL";
102
+ throw err;
103
+ }
104
+ const absRepo = fs.realpathSync(repo);
105
+ const projectName = name || path.basename(absRepo);
106
+ const templateDir = path.join(cfg.projectsDir, from);
107
+ const projectDir = path.join(cfg.projectsDir, projectName);
108
+
109
+ if (!fs.existsSync(templateDir)) {
110
+ const err = new Error(`template not found: ${templateDir}`);
111
+ err.code = "ENOENT";
112
+ throw err;
113
+ }
114
+
115
+ const actions = [];
116
+ if (fs.existsSync(projectDir)) {
117
+ const hasContent =
118
+ countEntries(path.join(projectDir, "rules")) +
119
+ countEntries(path.join(projectDir, "skills")) >
120
+ 0;
121
+ if (hasContent && !force) {
122
+ actions.push({ action: "exists", path: projectDir });
123
+ } else {
124
+ // Empty project dir, or --force: merge missing baseline files only
125
+ actions.push(...mergeTemplate(templateDir, projectDir, { dryRun }));
126
+ }
127
+ } else {
128
+ actions.push(copyDir(templateDir, projectDir, { dryRun }));
129
+ }
130
+
131
+ const link = path.join(absRepo, ".cursor");
132
+ actions.push(linkPath(projectDir, link, { force, dryRun }));
133
+ actions.push(ensureGitignore(absRepo, { dryRun }));
134
+
135
+ return {
136
+ project: projectName,
137
+ projectDir,
138
+ repo: absRepo,
139
+ from,
140
+ actions,
141
+ };
142
+ }
143
+
144
+ function countEntries(dir) {
145
+ if (!fs.existsSync(dir)) return 0;
146
+ return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length;
147
+ }
148
+
149
+ function mergeTemplate(templateDir, projectDir, { dryRun = false } = {}) {
150
+ const actions = [];
151
+ for (const kind of ["rules", "skills"]) {
152
+ const srcRoot = path.join(templateDir, kind);
153
+ const destRoot = path.join(projectDir, kind);
154
+ if (!fs.existsSync(srcRoot)) continue;
155
+ ensureDir(destRoot);
156
+ for (const name of fs.readdirSync(srcRoot)) {
157
+ if (name.startsWith(".")) continue;
158
+ const src = path.join(srcRoot, name);
159
+ const dest = path.join(destRoot, name);
160
+ if (fs.existsSync(dest)) {
161
+ actions.push({ action: "keep", path: dest });
162
+ continue;
163
+ }
164
+ if (dryRun) {
165
+ actions.push({ action: "would_add", path: dest, from: src });
166
+ } else {
167
+ fs.cpSync(src, dest, { recursive: true });
168
+ actions.push({ action: "added", path: dest, from: src });
169
+ }
170
+ }
171
+ }
172
+ return actions;
173
+ }
174
+
175
+ function applyTemplate({ project, dryRun = false } = {}) {
176
+ const cfg = resolveConfig();
177
+ if (!project) {
178
+ const err = new Error("missing --project <name>");
179
+ err.code = "EINVAL";
180
+ throw err;
181
+ }
182
+ const projectDir = path.join(cfg.projectsDir, project);
183
+ if (!fs.existsSync(projectDir)) {
184
+ const err = new Error(`project not found: ${projectDir}`);
185
+ err.code = "ENOENT";
186
+ throw err;
187
+ }
188
+ if (project === "template") {
189
+ const err = new Error("cannot apply-template onto template itself");
190
+ err.code = "EINVAL";
191
+ throw err;
192
+ }
193
+ const actions = mergeTemplate(cfg.templateDir, projectDir, { dryRun });
194
+ return { project, projectDir, actions };
195
+ }
196
+
197
+ function linkProject({ repo, name, force = false, dryRun = false } = {}) {
198
+ const cfg = resolveConfig();
199
+ if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
200
+ const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
201
+ err.code = "ENOENT";
202
+ throw err;
203
+ }
204
+ if (!repo || !fs.existsSync(repo)) {
205
+ const err = new Error("missing or invalid --repo <path>");
206
+ err.code = "EINVAL";
207
+ throw err;
208
+ }
209
+ const absRepo = fs.realpathSync(repo);
210
+ const projectName = name || path.basename(absRepo);
211
+ const projectDir = path.join(cfg.projectsDir, projectName);
212
+ if (!dryRun) {
213
+ ensureDir(path.join(projectDir, "rules"));
214
+ ensureDir(path.join(projectDir, "skills"));
215
+ }
216
+ const actions = [
217
+ linkPath(projectDir, path.join(absRepo, ".cursor"), { force, dryRun }),
218
+ ensureGitignore(absRepo, { dryRun }),
219
+ ];
220
+ return { project: projectName, projectDir, repo: absRepo, actions };
221
+ }
222
+
223
+ function runDoctor({ fix = false, force = false, agents = ["cursor"], dryRun = false } = {}) {
224
+ const cfg = resolveConfig();
225
+ const status = collectStatus({ full: true, agents });
226
+ const repairs = [];
227
+ if (fix || force) {
228
+ repairs.push(...ensureCursorLinks(cfg, { force: true, dryRun }));
229
+ repairs.push(...ensureAgentSkillLinks(cfg, agents, { force: true, dryRun }));
230
+ }
231
+ const after = fix || force ? collectStatus({ full: true, agents }) : status;
232
+ return {
233
+ before: {
234
+ problems: status.problems,
235
+ counts: status.counts,
236
+ },
237
+ repairs,
238
+ after: {
239
+ problems: after.problems,
240
+ counts: after.counts,
241
+ links: after.links,
242
+ projects: after.projects,
243
+ },
244
+ help: statusHelp(after),
245
+ };
246
+ }
247
+
248
+ module.exports = {
249
+ ensureCursorLinks,
250
+ ensureAgentSkillLinks,
251
+ ensureGitignore,
252
+ initProject,
253
+ applyTemplate,
254
+ linkProject,
255
+ runDoctor,
256
+ linkPath,
257
+ collectProjects,
258
+ };
package/lib/config.js ADDED
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ function resolveConfig(env = process.env) {
8
+ const home = os.homedir();
9
+ const dir =
10
+ env.AI_MD_DIR || env.CURSOR_MD_DIR || path.join(home, ".ai-md");
11
+ const remote =
12
+ env.AI_MD_REMOTE ||
13
+ env.CURSOR_MD_REMOTE ||
14
+ "https://github.com/dujavi/.ai-md.git";
15
+ return {
16
+ home,
17
+ dir,
18
+ remote,
19
+ cursorSkills: path.join(home, ".cursor", "skills"),
20
+ cursorRules: path.join(home, ".cursor", "rules"),
21
+ projectsDir: path.join(dir, "projects"),
22
+ templateDir: path.join(dir, "projects", "template"),
23
+ skillsDir: path.join(dir, "skills"),
24
+ rulesDir: path.join(dir, "rules"),
25
+ };
26
+ }
27
+
28
+ function countRules(rulesDir) {
29
+ if (!fs.existsSync(rulesDir)) return 0;
30
+ return fs
31
+ .readdirSync(rulesDir)
32
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
33
+ .length;
34
+ }
35
+
36
+ function countSkills(skillsDir) {
37
+ if (!fs.existsSync(skillsDir)) return 0;
38
+ return fs
39
+ .readdirSync(skillsDir, { withFileTypes: true })
40
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
41
+ .length;
42
+ }
43
+
44
+ function listSkillNames(skillsDir) {
45
+ if (!fs.existsSync(skillsDir)) return [];
46
+ return fs
47
+ .readdirSync(skillsDir, { withFileTypes: true })
48
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
49
+ .map((d) => d.name)
50
+ .sort();
51
+ }
52
+
53
+ function listRuleNames(rulesDir) {
54
+ if (!fs.existsSync(rulesDir)) return [];
55
+ return fs
56
+ .readdirSync(rulesDir)
57
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
58
+ .sort();
59
+ }
60
+
61
+ function symlinkState(linkPath, expected) {
62
+ let stat;
63
+ try {
64
+ stat = fs.lstatSync(linkPath);
65
+ } catch {
66
+ return { path: linkPath, state: "missing", target: null, expected };
67
+ }
68
+ if (!stat.isSymbolicLink()) {
69
+ return { path: linkPath, state: "not_symlink", target: null, expected };
70
+ }
71
+ const target = fs.readlinkSync(linkPath);
72
+ if (target === expected) {
73
+ return { path: linkPath, state: "ok", target, expected };
74
+ }
75
+ return { path: linkPath, state: "wrong_target", target, expected };
76
+ }
77
+
78
+ function agentSkillTargets(home, agents) {
79
+ const map = {
80
+ cursor: path.join(home, ".cursor", "skills"),
81
+ claude: path.join(home, ".claude", "skills"),
82
+ agents: path.join(home, ".agents", "skills"),
83
+ };
84
+ return (agents || [])
85
+ .map((a) => String(a).trim().toLowerCase())
86
+ .filter(Boolean)
87
+ .filter((name) => map[name])
88
+ .map((name) => ({ name, path: map[name] }));
89
+ }
90
+
91
+ module.exports = {
92
+ resolveConfig,
93
+ countRules,
94
+ countSkills,
95
+ listSkillNames,
96
+ listRuleNames,
97
+ symlinkState,
98
+ agentSkillTargets,
99
+ };
package/lib/output.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ const { encode } = require("@toon-format/toon");
4
+
5
+ function emit({ data, json = false, help = [] }) {
6
+ if (json) {
7
+ const payload = help.length ? { ...data, help } : data;
8
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
9
+ return;
10
+ }
11
+ process.stdout.write(`${encode(data)}\n`);
12
+ if (help.length) {
13
+ process.stdout.write(`help[${help.length}]:\n`);
14
+ for (const line of help) {
15
+ process.stdout.write(` ${JSON.stringify(line)}\n`);
16
+ }
17
+ }
18
+ }
19
+
20
+ function fail(message, { exitCode = 1, help = [], json = false } = {}) {
21
+ const data = {
22
+ error: message,
23
+ ...(help.length ? { help } : {}),
24
+ };
25
+ if (json) {
26
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
27
+ } else {
28
+ process.stdout.write(`${encode(data)}\n`);
29
+ if (help.length) {
30
+ process.stdout.write(`help[${help.length}]:\n`);
31
+ for (const line of help) {
32
+ process.stdout.write(` ${JSON.stringify(line)}\n`);
33
+ }
34
+ }
35
+ }
36
+ process.exitCode = exitCode;
37
+ }
38
+
39
+ module.exports = { emit, fail, encode };
package/lib/status.js ADDED
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+ const {
7
+ resolveConfig,
8
+ countRules,
9
+ countSkills,
10
+ listSkillNames,
11
+ listRuleNames,
12
+ symlinkState,
13
+ agentSkillTargets,
14
+ } = require("./config");
15
+
16
+ function git(repo, args) {
17
+ try {
18
+ return execFileSync("git", ["-C", repo, ...args], {
19
+ encoding: "utf8",
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ }).trim();
22
+ } catch (err) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function templateDrift(projectDir, templateDir) {
28
+ if (!fs.existsSync(templateDir)) {
29
+ return { missingRules: [], missingSkills: [], extraRules: [], extraSkills: [] };
30
+ }
31
+ const tRules = new Set(listRuleNames(path.join(templateDir, "rules")));
32
+ const tSkills = new Set(listSkillNames(path.join(templateDir, "skills")));
33
+ const pRules = new Set(listRuleNames(path.join(projectDir, "rules")));
34
+ const pSkills = new Set(listSkillNames(path.join(projectDir, "skills")));
35
+ return {
36
+ missingRules: [...tRules].filter((r) => !pRules.has(r)),
37
+ missingSkills: [...tSkills].filter((s) => !pSkills.has(s)),
38
+ extraRules: [...pRules].filter((r) => !tRules.has(r)),
39
+ extraSkills: [...pSkills].filter((s) => !tSkills.has(s)),
40
+ };
41
+ }
42
+
43
+ function collectProjects(cfg, { full = false } = {}) {
44
+ const projects = [];
45
+ if (!fs.existsSync(cfg.projectsDir)) return projects;
46
+ for (const name of fs.readdirSync(cfg.projectsDir).sort()) {
47
+ if (name.startsWith(".")) continue;
48
+ const projectDir = path.join(cfg.projectsDir, name);
49
+ 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
+ projects.push({
69
+ name,
70
+ kind: "project",
71
+ rules: countRules(path.join(projectDir, "rules")),
72
+ skills: countSkills(path.join(projectDir, "skills")),
73
+ missingRules: drift.missingRules.length,
74
+ missingSkills: drift.missingSkills.length,
75
+ ...(full
76
+ ? {
77
+ path: projectDir,
78
+ drift,
79
+ }
80
+ : {}),
81
+ });
82
+ }
83
+ return projects;
84
+ }
85
+
86
+ function collectStatus(opts = {}) {
87
+ const cfg = resolveConfig();
88
+ const full = Boolean(opts.full);
89
+ const agents = opts.agents || ["cursor"];
90
+
91
+ const exists = fs.existsSync(cfg.dir);
92
+ const isGit = exists && fs.existsSync(path.join(cfg.dir, ".git"));
93
+
94
+ const links = [
95
+ symlinkState(cfg.cursorSkills, cfg.skillsDir),
96
+ symlinkState(cfg.cursorRules, cfg.rulesDir),
97
+ ];
98
+
99
+ const agentLinks = agentSkillTargets(cfg.home, agents)
100
+ .filter((t) => t.name !== "cursor")
101
+ .map((t) => ({
102
+ agent: t.name,
103
+ ...symlinkState(t.path, cfg.skillsDir),
104
+ }));
105
+
106
+ const projects = collectProjects(cfg, { full });
107
+ const linkedProjects = projects.filter((p) => p.kind === "project");
108
+ const drifting = linkedProjects.filter(
109
+ (p) => p.missingRules > 0 || p.missingSkills > 0
110
+ );
111
+
112
+ let branch = null;
113
+ let remote = null;
114
+ let dirty = false;
115
+ let aheadBehind = null;
116
+ if (isGit) {
117
+ branch = git(cfg.dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
118
+ remote = git(cfg.dir, ["remote", "get-url", "origin"]);
119
+ const porcelain = git(cfg.dir, ["status", "--porcelain"]);
120
+ dirty = Boolean(porcelain && porcelain.length);
121
+ aheadBehind = git(cfg.dir, ["status", "-sb"]) || null;
122
+ }
123
+
124
+ const problems = [];
125
+ if (!exists) problems.push("ai_md_missing");
126
+ else if (!isGit) problems.push("ai_md_not_git");
127
+ for (const l of links) {
128
+ if (l.state !== "ok") problems.push(`cursor_${path.basename(l.path)}_${l.state}`);
129
+ }
130
+
131
+ return {
132
+ generatedAt: new Date().toISOString(),
133
+ dir: cfg.dir,
134
+ remote: remote || cfg.remote,
135
+ branch,
136
+ dirty,
137
+ statusLine: aheadBehind,
138
+ counts: {
139
+ rules: countRules(cfg.rulesDir),
140
+ skills: countSkills(cfg.skillsDir),
141
+ projects: linkedProjects.length,
142
+ drifting: drifting.length,
143
+ problems: problems.length,
144
+ },
145
+ links: links.map((l) => ({
146
+ path: l.path,
147
+ state: l.state,
148
+ ...(full ? { target: l.target, expected: l.expected } : {}),
149
+ })),
150
+ agentLinks: agentLinks.map((l) => ({
151
+ agent: l.agent,
152
+ path: l.path,
153
+ state: l.state,
154
+ ...(full ? { target: l.target, expected: l.expected } : {}),
155
+ })),
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
+ ),
168
+ problems,
169
+ };
170
+ }
171
+
172
+ function statusHelp(data) {
173
+ const help = [];
174
+ 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}");
176
+ }
177
+ if (data.links.some((l) => l.state !== "ok")) {
178
+ help.push("Run `ai-md doctor --fix` to repair ~/.cursor skills/rules symlinks");
179
+ }
180
+ if (data.counts.drifting > 0) {
181
+ help.push(
182
+ "Run `ai-md apply-template --project <name>` to merge missing baseline files from projects/template"
183
+ );
184
+ }
185
+ if (data.dirty) {
186
+ help.push('Run `ai-md push -m "<why>"` to commit and push private config');
187
+ } else {
188
+ help.push("Run `ai-md pull` before editing global skills/rules");
189
+ }
190
+ help.push("Run `ai-md init-project --repo <path>` to seed a project from template and link .cursor/");
191
+ help.push("Run `ai-md status --json` for machine-readable output");
192
+ return help;
193
+ }
194
+
195
+ module.exports = {
196
+ collectStatus,
197
+ collectProjects,
198
+ templateDrift,
199
+ statusHelp,
200
+ git,
201
+ };
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dujavi/ai-md",
3
- "version": "0.1.0",
4
- "description": "CLI to sync private ~/.ai-md Cursor skills/rules and install agent tools (grok, quota-axi)",
3
+ "version": "0.2.0",
4
+ "description": "AXI-shaped CLI for private ~/.ai-md skills/rules, project templates, and agent tool bootstrap",
5
5
  "bin": {
6
6
  "ai-md": "bin/ai-md.js"
7
7
  },
8
8
  "files": [
9
9
  "bin/",
10
+ "lib/",
10
11
  "scripts/",
11
12
  "README.md",
12
13
  "LICENSE"
@@ -15,7 +16,10 @@
15
16
  "node": ">=18"
16
17
  },
17
18
  "scripts": {
18
- "test": "node bin/ai-md.js --help"
19
+ "test": "node bin/ai-md.js --help && node bin/ai-md.js status --json >/dev/null"
20
+ },
21
+ "dependencies": {
22
+ "@toon-format/toon": "^2.3.1"
19
23
  },
20
24
  "keywords": [
21
25
  "cursor",