@dujavi/ai-md 0.1.0 → 0.3.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,49 @@
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`).
4
4
 
5
- | Layer | What | Where |
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`) |
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.
11
+ No personal content ships in this package. Reads default to TOON + `help[]` (`--json` available).
11
12
 
12
13
  ## Install
13
14
 
14
15
  ```bash
15
16
  npm i -g @dujavi/ai-md
16
- # or one-shot:
17
- npx @dujavi/ai-md --help
18
17
  ```
19
18
 
20
- ## New machine
21
-
22
- ```bash
23
- npm i -g @dujavi/ai-md
24
- export AI_MD_REMOTE=https://github.com/<you>/.ai-md.git # your private content repo
25
- ai-md install
26
- ai-md ensure-tools # grok + quota-axi
19
+ ## Layout idea
20
+
21
+ ```text
22
+ ~/.ai-md/
23
+ skills/ # system all agents see these
24
+ rules/
25
+ templates/
26
+ base/ # default project starter (customizable stubs only)
27
+ # forms/ … # other use cases
28
+ projects/
29
+ presenter/
30
+ sendfolio/
27
31
  ```
28
32
 
29
- Defaults (override with env):
30
-
31
- - `AI_MD_DIR` → `~/.ai-md`
32
- - `AI_MD_REMOTE` → `https://github.com/dujavi/.ai-md.git`
33
+ Put shared agentic skills/rules in **system** folders. Put only type-specific or per-project starters under **templates/**.
33
34
 
34
35
  ## Commands
35
36
 
36
37
  ```bash
37
- ai-md install
38
- ai-md pull
39
- ai-md push -m "why"
40
- ai-md status
41
- ai-md doctor --fix
42
- ai-md ensure-tools # alias: tools
43
- ai-md link-project --repo ~/my-app
38
+ ai-md # status
39
+ ai-md init-project --repo ~/app --from base
40
+ ai-md apply-template --project app --from base
41
+ ai-md doctor --fix --agents cursor,claude
42
+ ai-md pull | push -m "why"
43
+ ai-md ensure-tools
44
44
  ```
45
45
 
46
- `install` / `pull` / `doctor` keep:
47
-
48
- - `~/.cursor/skills` → `$AI_MD_DIR/skills`
49
- - `~/.cursor/rules` → `$AI_MD_DIR/rules`
46
+ ## Environment
50
47
 
51
- ## Agent-friendly
52
-
53
- Non-interactive flags only (`--dry-run`, `--force`, `--fix`, `-m`). No prompts. Prefer `npx -y @dujavi/ai-md <cmd>` when not installed globally.
48
+ - `AI_MD_DIR` → `~/.ai-md`
49
+ - `AI_MD_REMOTE` → private content git URL
package/bin/ai-md.js CHANGED
@@ -2,98 +2,329 @@
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: "base",
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: system skills/rules + templates/ + projects/ (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]
125
+
126
+ Layout:
127
+ ~/.ai-md/skills, rules System (global) base — linked to ~/.cursor
128
+ ~/.ai-md/templates/<type> Project-type starters (default: base)
129
+ ~/.ai-md/projects/<name> Per-app overlays (repo .cursor → here)
45
130
 
46
131
  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
132
+ status Snapshot (default when no command) [AXI]
133
+ doctor Diagnose links/projects; --fix repairs
134
+ install Clone remote if needed; link ~/.cursor + optional agents
135
+ pull | push Sync private git repo
136
+ ensure-tools Install/update grok + quota-axi (alias: tools)
137
+ init-project Seed projects/<name> from templates/<from> + link .cursor/
138
+ apply-template Merge missing files from a template into a project
139
+ link-project Link repo .cursor/ without seeding (alias: link)
140
+ help Show this help
55
141
 
56
- 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)
142
+ Options:
143
+ --json JSON instead of TOON (status/doctor/init/…)
144
+ --full Include paths and drift details
145
+ --agents <list> Skill link targets: cursor,claude,agents (default: cursor)
146
+ --repo <path> App repository root
147
+ --name <id> Project id under projects/ (default: basename)
148
+ --project <id> Target project for apply-template
149
+ --from <id> Template under templates/ (default: base)
150
+ --force Replace non-symlink .cursor / re-merge
151
+ --dry-run Preview without writing
152
+ --fix doctor: repair symlinks
153
+ -m, --message push commit message
59
154
 
60
155
  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.
156
+ ai-md
157
+ ai-md status --json
158
+ 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
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({
188
+ full: opts.full,
189
+ agents: opts.agents,
190
+ from: opts.from,
191
+ });
192
+ emit({ data, json: opts.json, help: statusHelp(data) });
193
+ process.exitCode = data.counts.problems > 0 ? 1 : 0;
194
+ break;
195
+ }
196
+ case "doctor": {
197
+ const data = runDoctor({
198
+ fix: opts.fix,
199
+ force: opts.force,
200
+ agents: opts.agents,
201
+ dryRun: opts.dryRun,
202
+ });
203
+ emit({
204
+ data,
205
+ json: opts.json,
206
+ help: data.help,
207
+ });
208
+ process.exitCode = data.after.problems.length > 0 ? 1 : 0;
209
+ break;
210
+ }
211
+ case "init-project": {
212
+ const data = initProject({
213
+ repo: opts.repo,
214
+ name: opts.name,
215
+ from: opts.from,
216
+ force: opts.force,
217
+ dryRun: opts.dryRun,
218
+ });
219
+ emit({
220
+ data,
221
+ json: opts.json,
222
+ help: [
223
+ "Customize rules/agentic-workflow.mdc for this project",
224
+ 'Run `ai-md push -m "Init <project>"` after reviewing the private repo diff',
225
+ ],
226
+ });
227
+ break;
228
+ }
229
+ case "apply-template": {
230
+ const data = applyTemplate({
231
+ project: opts.project || opts.name,
232
+ from: opts.from,
233
+ dryRun: opts.dryRun,
234
+ });
235
+ emit({
236
+ data,
237
+ json: opts.json,
238
+ help: [
239
+ "Review added files under ~/.ai-md/projects/<name>/",
240
+ 'Run `ai-md push -m "Apply template to <project>"` when ready',
241
+ ],
242
+ });
243
+ break;
244
+ }
245
+ case "link-project":
246
+ case "link": {
247
+ const data = linkProject({
248
+ repo: opts.repo,
249
+ name: opts.name,
250
+ force: opts.force,
251
+ dryRun: opts.dryRun,
252
+ });
253
+ emit({
254
+ data,
255
+ json: opts.json,
256
+ help: [
257
+ "Prefer `ai-md init-project --repo <path>` to seed from template first",
258
+ ],
259
+ });
260
+ break;
261
+ }
262
+ 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);
274
+ }
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
+ ];
282
+ const data = collectStatus({ full: opts.full, agents: opts.agents });
283
+ emit({
284
+ data: { install: "ok", links, ...data },
285
+ json: opts.json,
286
+ help: [
287
+ "Run `ai-md ensure-tools` to install grok + quota-axi",
288
+ "Run `ai-md init-project --repo <path>` for a new app",
289
+ ],
290
+ });
291
+ break;
292
+ }
293
+ case "pull":
294
+ runBash("sync-config.sh", [
295
+ "pull",
296
+ ...(opts.dryRun ? ["--dry-run"] : []),
297
+ ]);
298
+ break;
299
+ case "push": {
300
+ const args = ["push"];
301
+ if (opts.message) args.push("-m", opts.message);
302
+ if (opts.dryRun) args.push("--dry-run");
303
+ runBash("sync-config.sh", args);
304
+ break;
305
+ }
306
+ case "ensure-tools":
307
+ case "tools":
308
+ runBash("ensure-agent-tools.sh", [
309
+ ...(opts.dryRun ? ["--dry-run"] : []),
310
+ ]);
311
+ break;
312
+ default:
313
+ fail(`unknown command: ${opts.cmd}`, {
314
+ exitCode: 2,
315
+ json: opts.json,
316
+ help: ["Run `ai-md --help`"],
317
+ });
318
+ process.exit(2);
319
+ }
320
+ } catch (err) {
321
+ fail(err.message || String(err), {
322
+ exitCode: err.code === "EINVAL" ? 2 : 1,
323
+ json: opts.json,
324
+ help: ["Run `ai-md --help`"],
325
+ });
326
+ process.exit(process.exitCode || 1);
327
+ }
97
328
  }
98
329
 
99
- process.exit(result.status === null ? 1 : result.status);
330
+ main();
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const {
6
+ resolveConfig,
7
+ templatePath,
8
+ symlinkState,
9
+ agentSkillTargets,
10
+ } = require("./config");
11
+ const { collectStatus, statusHelp, collectProjects } = require("./status");
12
+
13
+ function ensureDir(p) {
14
+ fs.mkdirSync(p, { recursive: true });
15
+ }
16
+
17
+ function linkPath(target, link, { force = false, dryRun = false } = {}) {
18
+ ensureDir(path.dirname(link));
19
+ ensureDir(target);
20
+ const state = symlinkState(link, target);
21
+ if (state.state === "ok") {
22
+ return { path: link, action: "ok", target };
23
+ }
24
+ if (state.state === "not_symlink" && !force) {
25
+ const err = new Error(
26
+ `${link} exists and is not a symlink (use --force to replace)`
27
+ );
28
+ err.code = "EEXIST";
29
+ throw err;
30
+ }
31
+ if (dryRun) {
32
+ return { path: link, action: state.state === "missing" ? "would_link" : "would_repair", target };
33
+ }
34
+ if (fs.existsSync(link) || state.state !== "missing") {
35
+ try {
36
+ fs.lstatSync(link);
37
+ fs.rmSync(link, { recursive: true, force: true });
38
+ } catch {
39
+ /* missing */
40
+ }
41
+ }
42
+ fs.symlinkSync(target, link);
43
+ return {
44
+ path: link,
45
+ action: state.state === "missing" ? "linked" : "repaired",
46
+ target,
47
+ };
48
+ }
49
+
50
+ function ensureCursorLinks(cfg, opts = {}) {
51
+ return [
52
+ linkPath(cfg.skillsDir, cfg.cursorSkills, opts),
53
+ linkPath(cfg.rulesDir, cfg.cursorRules, opts),
54
+ ];
55
+ }
56
+
57
+ function ensureAgentSkillLinks(cfg, agents, opts = {}) {
58
+ return agentSkillTargets(cfg.home, agents)
59
+ .filter((t) => t.name !== "cursor") // cursor handled via ensureCursorLinks
60
+ .map((t) => linkPath(cfg.skillsDir, t.path, opts));
61
+ }
62
+
63
+ function ensureGitignore(repoPath, { dryRun = false } = {}) {
64
+ const gitignore = path.join(repoPath, ".gitignore");
65
+ if (!fs.existsSync(gitignore)) {
66
+ return { path: gitignore, action: "missing_gitignore" };
67
+ }
68
+ const text = fs.readFileSync(gitignore, "utf8");
69
+ const lines = text.split(/\r?\n/);
70
+ if (lines.some((l) => l.trim() === ".cursor/")) {
71
+ return { path: gitignore, action: "ok" };
72
+ }
73
+ if (dryRun) return { path: gitignore, action: "would_append" };
74
+ const suffix =
75
+ (text.endsWith("\n") || text.length === 0 ? "" : "\n") +
76
+ "\n# Personal Cursor config (symlink to ~/.ai-md)\n.cursor/\n";
77
+ fs.appendFileSync(gitignore, suffix);
78
+ return { path: gitignore, action: "appended" };
79
+ }
80
+
81
+ function copyDir(src, dest, { dryRun = false } = {}) {
82
+ if (dryRun) return { action: "would_copy", from: src, to: dest };
83
+ fs.cpSync(src, dest, { recursive: true, force: false, errorOnExist: false });
84
+ return { action: "copied", from: src, to: dest };
85
+ }
86
+
87
+ function initProject({
88
+ repo,
89
+ name,
90
+ from = "base",
91
+ force = false,
92
+ dryRun = false,
93
+ } = {}) {
94
+ const cfg = resolveConfig();
95
+ if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
96
+ const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
97
+ err.code = "ENOENT";
98
+ throw err;
99
+ }
100
+ if (!repo || !fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) {
101
+ const err = new Error(`missing or invalid --repo <path>`);
102
+ err.code = "EINVAL";
103
+ throw err;
104
+ }
105
+ const absRepo = fs.realpathSync(repo);
106
+ const projectName = name || path.basename(absRepo);
107
+ const tmplDir = templatePath(cfg, from);
108
+ const projectDir = path.join(cfg.projectsDir, projectName);
109
+
110
+ if (!fs.existsSync(tmplDir)) {
111
+ const err = new Error(
112
+ `template not found: ${tmplDir} (available under ${cfg.templatesDir}/)`
113
+ );
114
+ err.code = "ENOENT";
115
+ throw err;
116
+ }
117
+
118
+ const actions = [];
119
+ if (fs.existsSync(projectDir)) {
120
+ const hasContent =
121
+ countEntries(path.join(projectDir, "rules")) +
122
+ countEntries(path.join(projectDir, "skills")) >
123
+ 0;
124
+ if (hasContent && !force) {
125
+ actions.push({ action: "exists", path: projectDir });
126
+ } else {
127
+ actions.push(...mergeTemplate(tmplDir, projectDir, { dryRun }));
128
+ }
129
+ } else {
130
+ actions.push(copyDir(tmplDir, projectDir, { dryRun }));
131
+ }
132
+
133
+ const link = path.join(absRepo, ".cursor");
134
+ actions.push(linkPath(projectDir, link, { force, dryRun }));
135
+ actions.push(ensureGitignore(absRepo, { dryRun }));
136
+
137
+ return {
138
+ project: projectName,
139
+ projectDir,
140
+ repo: absRepo,
141
+ from,
142
+ templateDir: tmplDir,
143
+ actions,
144
+ };
145
+ }
146
+
147
+ function countEntries(dir) {
148
+ if (!fs.existsSync(dir)) return 0;
149
+ return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length;
150
+ }
151
+
152
+ function mergeTemplate(templateDir, projectDir, { dryRun = false } = {}) {
153
+ const actions = [];
154
+ for (const kind of ["rules", "skills"]) {
155
+ const srcRoot = path.join(templateDir, kind);
156
+ const destRoot = path.join(projectDir, kind);
157
+ if (!fs.existsSync(srcRoot)) continue;
158
+ ensureDir(destRoot);
159
+ for (const name of fs.readdirSync(srcRoot)) {
160
+ if (name.startsWith(".")) continue;
161
+ const src = path.join(srcRoot, name);
162
+ const dest = path.join(destRoot, name);
163
+ if (fs.existsSync(dest)) {
164
+ actions.push({ action: "keep", path: dest });
165
+ continue;
166
+ }
167
+ if (dryRun) {
168
+ actions.push({ action: "would_add", path: dest, from: src });
169
+ } else {
170
+ fs.cpSync(src, dest, { recursive: true });
171
+ actions.push({ action: "added", path: dest, from: src });
172
+ }
173
+ }
174
+ }
175
+ return actions;
176
+ }
177
+
178
+ function applyTemplate({ project, from = "base", dryRun = false } = {}) {
179
+ const cfg = resolveConfig();
180
+ if (!project) {
181
+ const err = new Error("missing --project <name>");
182
+ err.code = "EINVAL";
183
+ throw err;
184
+ }
185
+ const projectDir = path.join(cfg.projectsDir, project);
186
+ if (!fs.existsSync(projectDir)) {
187
+ const err = new Error(`project not found: ${projectDir}`);
188
+ err.code = "ENOENT";
189
+ throw err;
190
+ }
191
+ const tmplDir = templatePath(cfg, from);
192
+ if (!fs.existsSync(tmplDir)) {
193
+ const err = new Error(`template not found: ${tmplDir}`);
194
+ err.code = "ENOENT";
195
+ throw err;
196
+ }
197
+ const actions = mergeTemplate(tmplDir, projectDir, { dryRun });
198
+ return { project, projectDir, from, templateDir: tmplDir, actions };
199
+ }
200
+
201
+ function linkProject({ repo, name, force = false, dryRun = false } = {}) {
202
+ const cfg = resolveConfig();
203
+ if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
204
+ const err = new Error(`${cfg.dir} is not a git repo; run ai-md install first`);
205
+ err.code = "ENOENT";
206
+ throw err;
207
+ }
208
+ if (!repo || !fs.existsSync(repo)) {
209
+ const err = new Error("missing or invalid --repo <path>");
210
+ err.code = "EINVAL";
211
+ throw err;
212
+ }
213
+ const absRepo = fs.realpathSync(repo);
214
+ const projectName = name || path.basename(absRepo);
215
+ const projectDir = path.join(cfg.projectsDir, projectName);
216
+ if (!dryRun) {
217
+ ensureDir(path.join(projectDir, "rules"));
218
+ ensureDir(path.join(projectDir, "skills"));
219
+ }
220
+ const actions = [
221
+ linkPath(projectDir, path.join(absRepo, ".cursor"), { force, dryRun }),
222
+ ensureGitignore(absRepo, { dryRun }),
223
+ ];
224
+ return { project: projectName, projectDir, repo: absRepo, actions };
225
+ }
226
+
227
+ function runDoctor({ fix = false, force = false, agents = ["cursor"], dryRun = false } = {}) {
228
+ const cfg = resolveConfig();
229
+ const status = collectStatus({ full: true, agents });
230
+ const repairs = [];
231
+ if (fix || force) {
232
+ repairs.push(...ensureCursorLinks(cfg, { force: true, dryRun }));
233
+ repairs.push(...ensureAgentSkillLinks(cfg, agents, { force: true, dryRun }));
234
+ }
235
+ const after = fix || force ? collectStatus({ full: true, agents }) : status;
236
+ return {
237
+ before: {
238
+ problems: status.problems,
239
+ counts: status.counts,
240
+ },
241
+ repairs,
242
+ after: {
243
+ problems: after.problems,
244
+ counts: after.counts,
245
+ links: after.links,
246
+ projects: after.projects,
247
+ },
248
+ help: statusHelp(after),
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ ensureCursorLinks,
254
+ ensureAgentSkillLinks,
255
+ ensureGitignore,
256
+ initProject,
257
+ applyTemplate,
258
+ linkProject,
259
+ runDoctor,
260
+ linkPath,
261
+ collectProjects,
262
+ };
package/lib/config.js ADDED
@@ -0,0 +1,131 @@
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
+ const templatesDir = path.join(dir, "templates");
16
+ return {
17
+ home,
18
+ dir,
19
+ remote,
20
+ cursorSkills: path.join(home, ".cursor", "skills"),
21
+ cursorRules: path.join(home, ".cursor", "rules"),
22
+ projectsDir: path.join(dir, "projects"),
23
+ templatesDir,
24
+ /** @deprecated use templatePath(cfg, name) */
25
+ templateDir: path.join(templatesDir, "base"),
26
+ skillsDir: path.join(dir, "skills"),
27
+ rulesDir: path.join(dir, "rules"),
28
+ };
29
+ }
30
+
31
+ function templatePath(cfg, name = "base") {
32
+ return path.join(cfg.templatesDir, name);
33
+ }
34
+
35
+ function listTemplates(cfg) {
36
+ if (!fs.existsSync(cfg.templatesDir)) return [];
37
+ return fs
38
+ .readdirSync(cfg.templatesDir, { withFileTypes: true })
39
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
40
+ .map((d) => d.name)
41
+ .sort();
42
+ }
43
+
44
+ function countRules(rulesDir) {
45
+ if (!fs.existsSync(rulesDir)) return 0;
46
+ return fs
47
+ .readdirSync(rulesDir)
48
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
49
+ .length;
50
+ }
51
+
52
+ function countSkills(skillsDir) {
53
+ if (!fs.existsSync(skillsDir)) return 0;
54
+ return fs
55
+ .readdirSync(skillsDir, { withFileTypes: true })
56
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
57
+ .length;
58
+ }
59
+
60
+ function listSkillNames(skillsDir) {
61
+ if (!fs.existsSync(skillsDir)) return [];
62
+ return fs
63
+ .readdirSync(skillsDir, { withFileTypes: true })
64
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
65
+ .map((d) => d.name)
66
+ .sort();
67
+ }
68
+
69
+ function listRuleNames(rulesDir) {
70
+ if (!fs.existsSync(rulesDir)) return [];
71
+ return fs
72
+ .readdirSync(rulesDir)
73
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
74
+ .sort();
75
+ }
76
+
77
+ function symlinkState(linkPath, expected) {
78
+ let stat;
79
+ try {
80
+ stat = fs.lstatSync(linkPath);
81
+ } catch {
82
+ return { path: linkPath, state: "missing", target: null, expected };
83
+ }
84
+ if (!stat.isSymbolicLink()) {
85
+ return { path: linkPath, state: "not_symlink", target: null, expected };
86
+ }
87
+ const target = fs.readlinkSync(linkPath);
88
+ if (target === expected) {
89
+ return { path: linkPath, state: "ok", target, expected };
90
+ }
91
+ return { path: linkPath, state: "wrong_target", target, expected };
92
+ }
93
+
94
+ function agentSkillTargets(home, agents) {
95
+ const map = {
96
+ cursor: path.join(home, ".cursor", "skills"),
97
+ claude: path.join(home, ".claude", "skills"),
98
+ agents: path.join(home, ".agents", "skills"),
99
+ };
100
+ return (agents || [])
101
+ .map((a) => String(a).trim().toLowerCase())
102
+ .filter(Boolean)
103
+ .filter((name) => map[name])
104
+ .map((name) => ({ name, path: map[name] }));
105
+ }
106
+
107
+ /** One-time: projects/template → templates/base */
108
+ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
109
+ const legacy = path.join(cfg.projectsDir, "template");
110
+ const dest = templatePath(cfg, "base");
111
+ if (!fs.existsSync(legacy) || fs.existsSync(dest)) {
112
+ return { action: "skip" };
113
+ }
114
+ if (dryRun) return { action: "would_migrate", from: legacy, to: dest };
115
+ fs.mkdirSync(cfg.templatesDir, { recursive: true });
116
+ fs.renameSync(legacy, dest);
117
+ return { action: "migrated", from: legacy, to: dest };
118
+ }
119
+
120
+ module.exports = {
121
+ resolveConfig,
122
+ templatePath,
123
+ listTemplates,
124
+ migrateLegacyTemplate,
125
+ countRules,
126
+ countSkills,
127
+ listSkillNames,
128
+ listRuleNames,
129
+ symlinkState,
130
+ agentSkillTargets,
131
+ };
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,206 @@
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
+ templatePath,
9
+ listTemplates,
10
+ migrateLegacyTemplate,
11
+ countRules,
12
+ countSkills,
13
+ listSkillNames,
14
+ listRuleNames,
15
+ symlinkState,
16
+ agentSkillTargets,
17
+ } = require("./config");
18
+
19
+ function git(repo, args) {
20
+ try {
21
+ return execFileSync("git", ["-C", repo, ...args], {
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ }).trim();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function templateDrift(projectDir, tmplDir) {
31
+ if (!fs.existsSync(tmplDir)) {
32
+ return { missingRules: [], missingSkills: [], extraRules: [], extraSkills: [] };
33
+ }
34
+ const tRules = new Set(listRuleNames(path.join(tmplDir, "rules")));
35
+ const tSkills = new Set(listSkillNames(path.join(tmplDir, "skills")));
36
+ const pRules = new Set(listRuleNames(path.join(projectDir, "rules")));
37
+ const pSkills = new Set(listSkillNames(path.join(projectDir, "skills")));
38
+ return {
39
+ missingRules: [...tRules].filter((r) => !pRules.has(r)),
40
+ missingSkills: [...tSkills].filter((s) => !pSkills.has(s)),
41
+ extraRules: [...pRules].filter((r) => !tRules.has(r)),
42
+ extraSkills: [...pSkills].filter((s) => !tSkills.has(s)),
43
+ };
44
+ }
45
+
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" } = {}) {
59
+ const projects = [];
60
+ const tmplDir = templatePath(cfg, from);
61
+ if (!fs.existsSync(cfg.projectsDir)) return projects;
62
+ for (const name of fs.readdirSync(cfg.projectsDir).sort()) {
63
+ if (name.startsWith(".")) continue;
64
+ // legacy leftover
65
+ if (name === "template") continue;
66
+ const projectDir = path.join(cfg.projectsDir, name);
67
+ if (!fs.statSync(projectDir).isDirectory()) continue;
68
+ const drift = templateDrift(projectDir, tmplDir);
69
+ projects.push({
70
+ name,
71
+ kind: "project",
72
+ from,
73
+ rules: countRules(path.join(projectDir, "rules")),
74
+ skills: countSkills(path.join(projectDir, "skills")),
75
+ missingRules: drift.missingRules.length,
76
+ missingSkills: drift.missingSkills.length,
77
+ ...(full ? { path: projectDir, drift } : {}),
78
+ });
79
+ }
80
+ return projects;
81
+ }
82
+
83
+ function collectStatus(opts = {}) {
84
+ const cfg = resolveConfig();
85
+ const full = Boolean(opts.full);
86
+ const agents = opts.agents || ["cursor"];
87
+ const from = opts.from || "base";
88
+
89
+ const migration = migrateLegacyTemplate(cfg);
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 templates = collectTemplates(cfg, { full });
107
+ const projects = collectProjects(cfg, { full, from });
108
+ const drifting = projects.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
+ if (!fs.existsSync(templatePath(cfg, "base")) && templates.length === 0) {
131
+ problems.push("templates_missing");
132
+ }
133
+
134
+ return {
135
+ generatedAt: new Date().toISOString(),
136
+ dir: cfg.dir,
137
+ remote: remote || cfg.remote,
138
+ branch,
139
+ dirty,
140
+ statusLine: aheadBehind,
141
+ layout: {
142
+ system: { rules: cfg.rulesDir, skills: cfg.skillsDir },
143
+ templates: cfg.templatesDir,
144
+ projects: cfg.projectsDir,
145
+ },
146
+ migration,
147
+ counts: {
148
+ rules: countRules(cfg.rulesDir),
149
+ skills: countSkills(cfg.skillsDir),
150
+ templates: templates.length,
151
+ projects: projects.length,
152
+ drifting: drifting.length,
153
+ problems: problems.length,
154
+ },
155
+ links: links.map((l) => ({
156
+ path: l.path,
157
+ state: l.state,
158
+ ...(full ? { target: l.target, expected: l.expected } : {}),
159
+ })),
160
+ agentLinks: agentLinks.map((l) => ({
161
+ agent: l.agent,
162
+ path: l.path,
163
+ state: l.state,
164
+ ...(full ? { target: l.target, expected: l.expected } : {}),
165
+ })),
166
+ templates,
167
+ projects,
168
+ driftFrom: from,
169
+ problems,
170
+ };
171
+ }
172
+
173
+ function statusHelp(data) {
174
+ const help = [];
175
+ 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}");
177
+ }
178
+ if (data.links.some((l) => l.state !== "ok")) {
179
+ help.push("Run `ai-md doctor --fix` to repair ~/.cursor skills/rules symlinks");
180
+ }
181
+ if (data.problems.includes("templates_missing")) {
182
+ help.push("Create ~/.ai-md/templates/base with project starters (system skills/rules stay in skills/ and rules/)");
183
+ }
184
+ if (data.counts.drifting > 0) {
185
+ help.push(
186
+ `Run \`ai-md apply-template --project <name> --from ${data.driftFrom || "base"}\` to merge missing template files`
187
+ );
188
+ }
189
+ if (data.dirty) {
190
+ help.push('Run `ai-md push -m "<why>"` to commit and push private config');
191
+ } else {
192
+ help.push("Edit system skills/rules under ~/.ai-md/{skills,rules}; project overlays under projects/");
193
+ }
194
+ help.push("Run `ai-md init-project --repo <path> --from base` to seed a project");
195
+ help.push("Run `ai-md status --json` for machine-readable output");
196
+ return help;
197
+ }
198
+
199
+ module.exports = {
200
+ collectStatus,
201
+ collectProjects,
202
+ collectTemplates,
203
+ templateDrift,
204
+ statusHelp,
205
+ git,
206
+ };
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.3.0",
4
+ "description": "AXI-shaped CLI for ~/.ai-md system skills/rules, templates/, and projects/",
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",