@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 +29 -33
- package/bin/ai-md.js +303 -72
- package/lib/commands.js +262 -0
- package/lib/config.js +131 -0
- package/lib/output.js +39 -0
- package/lib/status.js +206 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,53 +1,49 @@
|
|
|
1
1
|
# ai-md
|
|
2
2
|
|
|
3
|
-
Public CLI for
|
|
3
|
+
Public **AXI-shaped** CLI for a **private** personal AI config directory (`~/.ai-md`).
|
|
4
4
|
|
|
5
|
-
| Layer |
|
|
6
|
-
|
|
7
|
-
| **
|
|
8
|
-
| **
|
|
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
|
|
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
|
-
##
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
ai-md
|
|
39
|
-
ai-md
|
|
40
|
-
ai-md
|
|
41
|
-
ai-md
|
|
42
|
-
ai-md ensure-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
|
-
|
|
47
|
-
|
|
48
|
-
- `~/.cursor/skills` → `$AI_MD_DIR/skills`
|
|
49
|
-
- `~/.cursor/rules` → `$AI_MD_DIR/rules`
|
|
46
|
+
## Environment
|
|
50
47
|
|
|
51
|
-
|
|
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
|
|
19
|
+
const cfg = resolveConfig();
|
|
10
20
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
120
|
+
process.stdout.write(`ai-md — private ~/.ai-md: system skills/rules + templates/ + projects/ (AXI-shaped)
|
|
41
121
|
|
|
42
122
|
Usage:
|
|
43
|
-
ai-md
|
|
44
|
-
npx @dujavi/ai-md
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
ai-md
|
|
63
|
-
ai-md
|
|
64
|
-
ai-md
|
|
65
|
-
ai-md
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
179
|
+
if (opts.cmd === "help" || opts.cmdHelp) {
|
|
180
|
+
printHelp();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
93
183
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
330
|
+
main();
|
package/lib/commands.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "CLI
|
|
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",
|