@jazpiper/rules-doctor 0.2.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/CHANGELOG.md +18 -0
- package/README.md +127 -97
- package/dist/adapters/copilot.js +15 -0
- package/dist/adapters/index.js +2 -2
- package/dist/index.js +745 -46
- package/package.json +8 -4
- package/dist/adapters/antigravity.js +0 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.3.0 - 2026-02-25
|
|
6
|
+
|
|
7
|
+
- Added `copilot` adapter support for `.github/copilot-instructions.md`.
|
|
8
|
+
- Switched Copilot output to marker-managed mode to preserve user-authored content.
|
|
9
|
+
- Added target presets for `init` (`all`, `core`, `copilot`).
|
|
10
|
+
- Added `preset apply` command for applying presets to existing `.agentrules/rules.yaml`.
|
|
11
|
+
- Added CI workflow template for drift detection via `rules-doctor check`.
|
|
12
|
+
- Expanded tests for shared `AGENTS.md`, Copilot marker re-sync, and `--import` + preset behavior.
|
|
13
|
+
- Improved README with troubleshooting and CI examples.
|
|
14
|
+
|
|
15
|
+
## 0.2.0 - 2026-02-25
|
|
16
|
+
|
|
17
|
+
- Added multi-target adapters for `claude`, `codex`, `cursor`, `gemini`, and `opencode`.
|
|
18
|
+
- Added `init --import`, dry-run default for `sync`, drift `check`, and structural `doctor`.
|
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# @jazpiper/rules-doctor
|
|
2
2
|
|
|
3
|
-
`rules-doctor`
|
|
3
|
+
`rules-doctor` keeps coding-rule files in sync across multiple agent CLIs from one source of truth: `.agentrules/rules.yaml`.
|
|
4
4
|
|
|
5
|
-
It is
|
|
5
|
+
It is optimized for real project adoption:
|
|
6
|
+
- import existing docs (`init --import`)
|
|
7
|
+
- target presets (`init --preset all|core|copilot`)
|
|
8
|
+
- safe previews by default (`sync` is dry-run unless `--write`)
|
|
9
|
+
- drift detection for CI (`check`)
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
All paths are resolved from project root (`.git` ancestor), not current subdirectory.
|
|
8
12
|
|
|
9
13
|
## Install
|
|
10
14
|
|
|
@@ -12,143 +16,169 @@ By default, commands resolve paths relative to the project root (`.git` ancestor
|
|
|
12
16
|
npm install -D @jazpiper/rules-doctor
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
Run with:
|
|
16
20
|
|
|
17
21
|
```bash
|
|
18
|
-
npx
|
|
22
|
+
npx rules-doctor --help
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
##
|
|
25
|
+
## Quick Start
|
|
22
26
|
|
|
23
|
-
### 1) Initialize
|
|
27
|
+
### 1) Initialize (recommended with import)
|
|
24
28
|
|
|
25
29
|
```bash
|
|
26
|
-
rules-doctor init
|
|
30
|
+
npx rules-doctor init --import
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
Creates `.agentrules/rules.yaml`
|
|
33
|
+
- Creates `.agentrules/rules.yaml`
|
|
34
|
+
- Reads existing docs when found (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`)
|
|
35
|
+
- Writes `.agentrules/import-report.md`
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
- `npm run lint`
|
|
33
|
-
- `npm run test`
|
|
34
|
-
- `npm run build`
|
|
35
|
-
- Missing scripts are created as TODO placeholders.
|
|
37
|
+
Preset example (Copilot only):
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
```bash
|
|
40
|
+
npx rules-doctor init --preset copilot
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Preset meanings:
|
|
44
|
+
- `all`: all built-in targets enabled
|
|
45
|
+
- `core`: `claude`, `codex`, `opencode`, `cursor`, `gemini`
|
|
46
|
+
- `copilot`: only `copilot` enabled
|
|
47
|
+
|
|
48
|
+
Apply preset to an existing `.agentrules/rules.yaml`:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx rules-doctor preset apply copilot --write
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2) Preview changes safely
|
|
38
55
|
|
|
39
56
|
```bash
|
|
40
|
-
rules-doctor sync
|
|
41
|
-
rules-doctor sync --target claude
|
|
42
|
-
rules-doctor sync --target codex
|
|
43
|
-
rules-doctor sync --target cursor,gemini,opencode,antigravity
|
|
57
|
+
npx rules-doctor sync --diff
|
|
44
58
|
```
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
- `CLAUDE.md` (`claude`, fully managed)
|
|
48
|
-
- `AGENTS.md` (`codex`, `opencode`, marker-managed)
|
|
49
|
-
- `.cursor/rules/rules-doctor.mdc` (`cursor`, fully managed)
|
|
50
|
-
- `GEMINI.md` (`gemini`, fully managed)
|
|
51
|
-
- `GEMINI.md` (`antigravity`, inferred-compatible mapping)
|
|
60
|
+
`sync` is dry-run by default. Nothing is written yet.
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
### 3) Apply changes
|
|
54
63
|
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
... managed content ...
|
|
58
|
-
<!-- RULES_DOCTOR:END -->
|
|
64
|
+
```bash
|
|
65
|
+
npx rules-doctor sync --write
|
|
59
66
|
```
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
Optional backups:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx rules-doctor sync --write --backup
|
|
72
|
+
```
|
|
62
73
|
|
|
63
|
-
###
|
|
74
|
+
### 4) Verify drift in CI/local
|
|
64
75
|
|
|
65
76
|
```bash
|
|
66
|
-
rules-doctor
|
|
77
|
+
npx rules-doctor check
|
|
67
78
|
```
|
|
68
79
|
|
|
69
|
-
|
|
70
|
-
- missing markers
|
|
71
|
-
- missing verify commands (`lint`/`test`/`build`)
|
|
72
|
-
- obvious contradictions (simple heuristics across generated targets)
|
|
80
|
+
Returns non-zero when generated targets are out of sync.
|
|
73
81
|
|
|
74
|
-
|
|
82
|
+
## Supported Targets
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
```bash
|
|
85
|
+
npx rules-doctor targets list
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Built-in adapters:
|
|
89
|
+
- `claude` -> `CLAUDE.md` (full-managed)
|
|
90
|
+
- `codex` -> `AGENTS.md` (marker-managed)
|
|
91
|
+
- `copilot` -> `.github/copilot-instructions.md` (marker-managed, preserves existing text outside managed block)
|
|
92
|
+
- `opencode` -> `AGENTS.md` (marker-managed)
|
|
93
|
+
- `cursor` -> `.cursor/rules/rules-doctor.mdc` (full-managed)
|
|
94
|
+
- `gemini` -> `GEMINI.md` (full-managed)
|
|
95
|
+
|
|
96
|
+
## Command Reference
|
|
97
|
+
|
|
98
|
+
### `init`
|
|
77
99
|
|
|
78
100
|
```bash
|
|
79
|
-
rules-doctor
|
|
101
|
+
npx rules-doctor init [--import]
|
|
102
|
+
npx rules-doctor init [--import] [--preset all|core|copilot]
|
|
80
103
|
```
|
|
81
104
|
|
|
82
|
-
|
|
105
|
+
### `preset apply`
|
|
83
106
|
|
|
84
|
-
|
|
107
|
+
```bash
|
|
108
|
+
npx rules-doctor preset apply <all|core|copilot> [--diff] [--write]
|
|
109
|
+
```
|
|
85
110
|
|
|
86
|
-
|
|
87
|
-
- `codex`: looks for `AGENTS.override.md` or `AGENTS.md` from project root down to current directory.
|
|
88
|
-
- `opencode`: reads project `AGENTS.md`, plus user global `~/.config/opencode/AGENTS.md`.
|
|
89
|
-
- `cursor`: project rules live in `.cursor/rules/*.mdc` (legacy `.cursorrules` still exists).
|
|
90
|
-
- `gemini`: uses `GEMINI.md` in workspace/ancestor directories and `~/.gemini/GEMINI.md`.
|
|
91
|
-
- `antigravity`: currently mapped to `GEMINI.md` as an inferred default; verify in your environment.
|
|
111
|
+
### `sync`
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
```bash
|
|
114
|
+
npx rules-doctor sync [--target all|claude,codex,...] [--diff] [--write] [--backup]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `check`
|
|
94
118
|
|
|
95
|
-
|
|
119
|
+
```bash
|
|
120
|
+
npx rules-doctor check [--target all|claude,codex,...] [--diff]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `analyze`
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npx rules-doctor analyze [--strict]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `doctor`
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npx rules-doctor doctor [--strict]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## CI Template
|
|
136
|
+
|
|
137
|
+
Copy [docs/workflows/rules-doctor-check.yml](docs/workflows/rules-doctor-check.yml) to your repository as `.github/workflows/rules-doctor-check.yml`.
|
|
138
|
+
It runs `npx rules-doctor check` on push and pull requests.
|
|
139
|
+
|
|
140
|
+
Inline workflow example:
|
|
96
141
|
|
|
97
142
|
```yaml
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
enabled: true
|
|
130
|
-
path: "AGENTS.md"
|
|
131
|
-
antigravity:
|
|
132
|
-
enabled: true
|
|
133
|
-
path: "GEMINI.md"
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
You can disable or relocate any target by editing `targets.<id>.enabled/path`.
|
|
143
|
+
name: Rules Doctor Check
|
|
144
|
+
|
|
145
|
+
on:
|
|
146
|
+
push:
|
|
147
|
+
pull_request:
|
|
148
|
+
|
|
149
|
+
jobs:
|
|
150
|
+
rules-doctor:
|
|
151
|
+
runs-on: ubuntu-latest
|
|
152
|
+
steps:
|
|
153
|
+
- uses: actions/checkout@v4
|
|
154
|
+
- uses: actions/setup-node@v4
|
|
155
|
+
with:
|
|
156
|
+
node-version: "20"
|
|
157
|
+
cache: npm
|
|
158
|
+
- run: npm ci
|
|
159
|
+
- run: npx rules-doctor check
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Troubleshooting
|
|
163
|
+
|
|
164
|
+
- `rules-doctor: command not found` after `npm install -D @jazpiper/rules-doctor`:
|
|
165
|
+
- Use `npx rules-doctor ...` (recommended for local dev dependency).
|
|
166
|
+
- Or add an npm script in your project: `"rules:check": "rules-doctor check"`, then run `npm run rules:check`.
|
|
167
|
+
- Global install (`npm i -g @jazpiper/rules-doctor`) works, but local + `npx` is safer for version consistency.
|
|
168
|
+
- `init` says `rules.yaml already exists`:
|
|
169
|
+
- Use `npx rules-doctor preset apply <preset> --write` to change target defaults on an existing project.
|
|
170
|
+
|
|
171
|
+
## Rules Schema (v2 Draft)
|
|
172
|
+
|
|
173
|
+
See [docs/rules-v2-draft.yaml](docs/rules-v2-draft.yaml).
|
|
137
174
|
|
|
138
175
|
## Development
|
|
139
176
|
|
|
140
177
|
```bash
|
|
141
178
|
npm ci
|
|
142
|
-
npm
|
|
179
|
+
npm test
|
|
143
180
|
```
|
|
144
181
|
|
|
145
182
|
## License
|
|
146
183
|
|
|
147
184
|
MIT
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
## CI
|
|
151
|
-
|
|
152
|
-
This repo includes a GitHub Actions workflow template at `docs/workflows/ci.yml`.
|
|
153
|
-
|
|
154
|
-
If you want CI, copy it to `.github/workflows/ci.yml` and push the change.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { renderManagedRulesBody } = require("./common");
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
id: "copilot",
|
|
5
|
+
name: "GitHub Copilot",
|
|
6
|
+
description:
|
|
7
|
+
"Manage .github/copilot-instructions.md via marker-managed section to preserve user content.",
|
|
8
|
+
defaultPath: ".github/copilot-instructions.md",
|
|
9
|
+
management: "marker",
|
|
10
|
+
markerBegin: "<!-- RULES_DOCTOR:COPILOT:BEGIN -->",
|
|
11
|
+
markerEnd: "<!-- RULES_DOCTOR:COPILOT:END -->",
|
|
12
|
+
render(rules) {
|
|
13
|
+
return ["# Copilot Instructions", "", renderManagedRulesBody(rules)].join("\n");
|
|
14
|
+
},
|
|
15
|
+
};
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
const claude = require("./claude");
|
|
2
2
|
const codex = require("./codex");
|
|
3
|
+
const copilot = require("./copilot");
|
|
3
4
|
const cursor = require("./cursor");
|
|
4
5
|
const gemini = require("./gemini");
|
|
5
6
|
const opencode = require("./opencode");
|
|
6
|
-
const antigravity = require("./antigravity");
|
|
7
7
|
|
|
8
|
-
const ADAPTERS = [claude, codex, cursor, gemini, opencode
|
|
8
|
+
const ADAPTERS = [claude, codex, copilot, cursor, gemini, opencode];
|
|
9
9
|
const ADAPTERS_BY_ID = Object.fromEntries(ADAPTERS.map((adapter) => [adapter.id, adapter]));
|
|
10
10
|
|
|
11
11
|
module.exports = {
|
package/dist/index.js
CHANGED
|
@@ -4,17 +4,26 @@ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"
|
|
|
4
4
|
const { ADAPTERS, ADAPTERS_BY_ID } = require("./adapters");
|
|
5
5
|
|
|
6
6
|
const RULES_RELATIVE_PATH = ".agentrules/rules.yaml";
|
|
7
|
+
const IMPORT_REPORT_RELATIVE_PATH = ".agentrules/import-report.md";
|
|
8
|
+
const PRESET_NAMES = ["all", "core", "copilot"];
|
|
7
9
|
|
|
8
10
|
function usage() {
|
|
9
11
|
const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
|
|
12
|
+
const presets = PRESET_NAMES.join("|");
|
|
10
13
|
return [
|
|
11
14
|
"rules-doctor",
|
|
12
15
|
"",
|
|
13
16
|
"Usage:",
|
|
14
|
-
|
|
15
|
-
` rules-doctor
|
|
16
|
-
|
|
17
|
+
` rules-doctor init [--import] [--preset ${presets}]`,
|
|
18
|
+
` rules-doctor preset apply <${presets}> [--diff] [--write]`,
|
|
19
|
+
` rules-doctor sync [--target all|${targets}|<comma-separated-targets>] [--diff] [--write] [--backup]`,
|
|
20
|
+
` rules-doctor check [--target all|${targets}|<comma-separated-targets>] [--diff]`,
|
|
21
|
+
" rules-doctor analyze [--strict]",
|
|
22
|
+
" rules-doctor doctor [--strict]",
|
|
17
23
|
" rules-doctor targets list",
|
|
24
|
+
"",
|
|
25
|
+
"Notes:",
|
|
26
|
+
" - sync defaults to dry-run. Add --write to apply changes.",
|
|
18
27
|
].join("\n");
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -239,6 +248,40 @@ function createDefaultRules(scripts) {
|
|
|
239
248
|
};
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
function getPresetTargetIds(presetName) {
|
|
252
|
+
if (presetName === "all") {
|
|
253
|
+
return ADAPTERS.map((adapter) => adapter.id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (presetName === "core") {
|
|
257
|
+
return ["claude", "codex", "opencode", "cursor", "gemini"];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (presetName === "copilot") {
|
|
261
|
+
return ["copilot"];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function applyTargetPreset(rules, presetName) {
|
|
268
|
+
const enabled = new Set(getPresetTargetIds(presetName));
|
|
269
|
+
const targets = rules.targets && typeof rules.targets === "object" ? rules.targets : {};
|
|
270
|
+
|
|
271
|
+
for (const adapter of ADAPTERS) {
|
|
272
|
+
const current = normalizeTargetConfig(targets[adapter.id], adapter.defaultPath);
|
|
273
|
+
targets[adapter.id] = {
|
|
274
|
+
enabled: enabled.has(adapter.id),
|
|
275
|
+
path: current.path,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
...rules,
|
|
281
|
+
targets,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
242
285
|
function normalizeTargetConfig(source, fallbackPath) {
|
|
243
286
|
if (typeof source === "string" && source.trim()) {
|
|
244
287
|
return { enabled: true, path: source.trim() };
|
|
@@ -275,7 +318,14 @@ function normalizeRules(input, defaults) {
|
|
|
275
318
|
|
|
276
319
|
const targets = {};
|
|
277
320
|
for (const adapter of ADAPTERS) {
|
|
278
|
-
|
|
321
|
+
const fallback = defaults.targets[adapter.id]
|
|
322
|
+
? defaults.targets[adapter.id].path
|
|
323
|
+
: adapter.defaultPath;
|
|
324
|
+
const config = normalizeTargetConfig(sourceTargets[adapter.id], fallback);
|
|
325
|
+
targets[adapter.id] = {
|
|
326
|
+
enabled: typeof config.enabled === "boolean" ? config.enabled : true,
|
|
327
|
+
path: config.path,
|
|
328
|
+
};
|
|
279
329
|
}
|
|
280
330
|
|
|
281
331
|
for (const customId of Object.keys(sourceTargets)) {
|
|
@@ -464,43 +514,153 @@ function getTargetsFromSpec(spec) {
|
|
|
464
514
|
return unique;
|
|
465
515
|
}
|
|
466
516
|
|
|
467
|
-
function
|
|
517
|
+
function parseInitArgs(args) {
|
|
518
|
+
const options = {
|
|
519
|
+
importExisting: false,
|
|
520
|
+
preset: "all",
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
for (let index = 0; index < (args || []).length; index += 1) {
|
|
524
|
+
const arg = args[index];
|
|
525
|
+
if (arg === "--import") {
|
|
526
|
+
options.importExisting = true;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (arg === "--preset") {
|
|
531
|
+
const value = args[index + 1];
|
|
532
|
+
if (!value) {
|
|
533
|
+
throw new Error("Missing value for --preset.");
|
|
534
|
+
}
|
|
535
|
+
if (!PRESET_NAMES.includes(value)) {
|
|
536
|
+
throw new Error(`Unknown preset "${value}". Use one of: ${PRESET_NAMES.join(", ")}`);
|
|
537
|
+
}
|
|
538
|
+
options.preset = value;
|
|
539
|
+
index += 1;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
throw new Error(`Unknown option for init: ${arg}`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return options;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function parsePresetArgs(args) {
|
|
468
550
|
if (!args || args.length === 0) {
|
|
469
|
-
|
|
551
|
+
throw new Error(`Missing preset subcommand. Use "rules-doctor preset apply <${PRESET_NAMES.join("|")}>".`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const [subcommand, presetName, ...rest] = args;
|
|
555
|
+
if (subcommand !== "apply") {
|
|
556
|
+
throw new Error(`Unknown preset subcommand: ${subcommand}. Use "rules-doctor preset apply <preset>".`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!presetName) {
|
|
560
|
+
throw new Error(`Missing preset name. Use one of: ${PRESET_NAMES.join(", ")}`);
|
|
561
|
+
}
|
|
562
|
+
if (!PRESET_NAMES.includes(presetName)) {
|
|
563
|
+
throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const options = {
|
|
567
|
+
presetName,
|
|
568
|
+
diff: false,
|
|
569
|
+
write: false,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
for (const arg of rest) {
|
|
573
|
+
if (arg === "--diff") {
|
|
574
|
+
options.diff = true;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (arg === "--write") {
|
|
578
|
+
options.write = true;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
throw new Error(`Unknown option for preset apply: ${arg}`);
|
|
470
582
|
}
|
|
471
583
|
|
|
472
|
-
|
|
473
|
-
|
|
584
|
+
return options;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseTargetedArgs(commandName, args, extra) {
|
|
588
|
+
const options = {
|
|
589
|
+
targetSpec: "all",
|
|
590
|
+
diff: false,
|
|
591
|
+
write: false,
|
|
592
|
+
backup: false,
|
|
593
|
+
};
|
|
594
|
+
const allowed = extra || {};
|
|
595
|
+
|
|
596
|
+
for (let index = 0; index < (args || []).length; index += 1) {
|
|
474
597
|
const arg = args[index];
|
|
475
598
|
if (arg === "--target") {
|
|
476
599
|
const value = args[index + 1];
|
|
477
600
|
if (!value) {
|
|
478
|
-
throw new Error(
|
|
601
|
+
throw new Error(`Missing value for --target (${commandName})`);
|
|
479
602
|
}
|
|
480
|
-
targetSpec = value;
|
|
603
|
+
options.targetSpec = value;
|
|
481
604
|
index += 1;
|
|
482
605
|
continue;
|
|
483
606
|
}
|
|
607
|
+
if (arg === "--diff") {
|
|
608
|
+
options.diff = true;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (arg === "--write" && allowed.write) {
|
|
612
|
+
options.write = true;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (arg === "--backup" && allowed.backup) {
|
|
616
|
+
options.backup = true;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
throw new Error(`Unknown option for ${commandName}: ${arg}`);
|
|
620
|
+
}
|
|
484
621
|
|
|
485
|
-
|
|
622
|
+
if (options.backup && !options.write) {
|
|
623
|
+
throw new Error("--backup requires --write.");
|
|
486
624
|
}
|
|
487
625
|
|
|
488
|
-
return
|
|
626
|
+
return {
|
|
627
|
+
targetIds: getTargetsFromSpec(options.targetSpec),
|
|
628
|
+
diff: options.diff,
|
|
629
|
+
write: options.write,
|
|
630
|
+
backup: options.backup,
|
|
631
|
+
};
|
|
489
632
|
}
|
|
490
633
|
|
|
491
634
|
function parseAnalyzeArgs(args) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
635
|
+
const options = {
|
|
636
|
+
strict: false,
|
|
637
|
+
};
|
|
495
638
|
|
|
496
|
-
for (const arg of args) {
|
|
639
|
+
for (const arg of args || []) {
|
|
497
640
|
if (arg === "--strict") {
|
|
498
|
-
|
|
641
|
+
options.strict = true;
|
|
642
|
+
continue;
|
|
499
643
|
}
|
|
500
644
|
throw new Error(`Unknown option for analyze: ${arg}`);
|
|
501
645
|
}
|
|
502
646
|
|
|
503
|
-
return
|
|
647
|
+
return options;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function parseDoctorArgs(args) {
|
|
651
|
+
const options = {
|
|
652
|
+
strict: false,
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
for (const arg of args || []) {
|
|
656
|
+
if (arg === "--strict") {
|
|
657
|
+
options.strict = true;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
throw new Error(`Unknown option for doctor: ${arg}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return options;
|
|
504
664
|
}
|
|
505
665
|
|
|
506
666
|
function getTargetConfig(rules, adapter) {
|
|
@@ -508,57 +668,535 @@ function getTargetConfig(rules, adapter) {
|
|
|
508
668
|
return normalizeTargetConfig(source, adapter.defaultPath);
|
|
509
669
|
}
|
|
510
670
|
|
|
511
|
-
function
|
|
671
|
+
function normalizeHeading(value) {
|
|
672
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseMarkdownSections(text) {
|
|
676
|
+
const sections = {};
|
|
677
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
678
|
+
let currentHeading = null;
|
|
679
|
+
let currentLines = [];
|
|
680
|
+
|
|
681
|
+
function flush() {
|
|
682
|
+
if (!currentHeading) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const normalized = normalizeHeading(currentHeading);
|
|
686
|
+
if (!sections[normalized]) {
|
|
687
|
+
sections[normalized] = currentLines.join("\n").trim();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const line of lines) {
|
|
692
|
+
const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
693
|
+
if (heading) {
|
|
694
|
+
flush();
|
|
695
|
+
currentHeading = heading[1];
|
|
696
|
+
currentLines = [];
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (currentHeading) {
|
|
701
|
+
currentLines.push(line);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
flush();
|
|
706
|
+
return sections;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function pickFirstNonEmptyLine(text) {
|
|
710
|
+
for (const line of text.split("\n")) {
|
|
711
|
+
const trimmed = line.trim();
|
|
712
|
+
if (trimmed) {
|
|
713
|
+
return trimmed;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return "";
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function parseListItems(text) {
|
|
720
|
+
const items = [];
|
|
721
|
+
for (const line of text.split("\n")) {
|
|
722
|
+
const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
|
|
723
|
+
if (bullet) {
|
|
724
|
+
items.push(bullet[1].trim());
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const numbered = line.match(/^\s*\d+\.\s+(.+?)\s*$/);
|
|
728
|
+
if (numbered) {
|
|
729
|
+
items.push(numbered[1].trim());
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return items;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function unquoteValue(value) {
|
|
736
|
+
let current = value.trim();
|
|
737
|
+
if (current.startsWith("`") && current.endsWith("`")) {
|
|
738
|
+
current = current.slice(1, -1);
|
|
739
|
+
}
|
|
740
|
+
current = stripQuotes(current);
|
|
741
|
+
return current.trim();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function importCommandsFromText(text, commands) {
|
|
745
|
+
const merged = { ...commands };
|
|
746
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
747
|
+
const commandNames = Object.keys(merged);
|
|
748
|
+
|
|
749
|
+
for (const raw of lines) {
|
|
750
|
+
const line = raw.trim();
|
|
751
|
+
for (const name of commandNames) {
|
|
752
|
+
const direct = line.match(new RegExp(`^(?:[-*]\\s*)?${name}\\s*:\\s*(.+)$`, "i"));
|
|
753
|
+
if (direct && direct[1]) {
|
|
754
|
+
merged[name] = unquoteValue(direct[1]);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for (const name of commandNames) {
|
|
760
|
+
if (!merged[name] || merged[name].includes("TODO")) {
|
|
761
|
+
const found = text.match(new RegExp(`\\b(?:npm run|pnpm|yarn|bun)\\s+${name}\\b`, "i"));
|
|
762
|
+
if (found) {
|
|
763
|
+
merged[name] = found[0];
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return merged;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function getSectionText(sections, aliases) {
|
|
772
|
+
for (const alias of aliases) {
|
|
773
|
+
const key = normalizeHeading(alias);
|
|
774
|
+
if (sections[key] && sections[key].trim()) {
|
|
775
|
+
return sections[key].trim();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return "";
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function collectImportSources(rootDir) {
|
|
782
|
+
const candidates = [
|
|
783
|
+
{ id: "claude", path: "CLAUDE.md" },
|
|
784
|
+
{ id: "claude-local", path: ".claude/CLAUDE.md" },
|
|
785
|
+
{ id: "codex", path: "AGENTS.md" },
|
|
786
|
+
{ id: "copilot", path: ".github/copilot-instructions.md" },
|
|
787
|
+
{ id: "gemini", path: "GEMINI.md" },
|
|
788
|
+
{ id: "cursor", path: ".cursor/rules/rules-doctor.mdc" },
|
|
789
|
+
];
|
|
790
|
+
|
|
791
|
+
const uniquePaths = new Set();
|
|
792
|
+
const sources = [];
|
|
793
|
+
|
|
794
|
+
for (const item of candidates) {
|
|
795
|
+
if (uniquePaths.has(item.path)) {
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
uniquePaths.add(item.path);
|
|
799
|
+
|
|
800
|
+
const absolutePath = resolveInRoot(rootDir, item.path);
|
|
801
|
+
if (!existsSync(absolutePath)) {
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
sources.push({
|
|
805
|
+
id: item.id,
|
|
806
|
+
path: item.path,
|
|
807
|
+
absolutePath,
|
|
808
|
+
text: readFileSync(absolutePath, "utf8"),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return sources;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function importRulesFromDocs(rootDir, defaults) {
|
|
816
|
+
const imported = JSON.parse(JSON.stringify(defaults));
|
|
817
|
+
const sources = collectImportSources(rootDir);
|
|
818
|
+
const notes = [];
|
|
819
|
+
|
|
820
|
+
if (sources.length === 0) {
|
|
821
|
+
return {
|
|
822
|
+
rules: imported,
|
|
823
|
+
report: "No existing docs were found. Created default rules.",
|
|
824
|
+
sources,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
notes.push(`Found ${sources.length} source file(s):`);
|
|
829
|
+
for (const source of sources) {
|
|
830
|
+
notes.push(`- ${source.path}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
for (const source of sources) {
|
|
834
|
+
const sections = parseMarkdownSections(source.text);
|
|
835
|
+
|
|
836
|
+
const missionSection = getSectionText(sections, ["mission"]);
|
|
837
|
+
if (missionSection) {
|
|
838
|
+
const mission = pickFirstNonEmptyLine(missionSection);
|
|
839
|
+
if (mission) {
|
|
840
|
+
imported.mission = mission;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const workflowSection = getSectionText(sections, ["workflow", "operational loop"]);
|
|
845
|
+
const workflow = parseListItems(workflowSection);
|
|
846
|
+
if (workflow.length > 0) {
|
|
847
|
+
imported.workflow = workflow;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const doneSection = getSectionText(sections, ["done", "done criteria"]);
|
|
851
|
+
const done = parseListItems(doneSection);
|
|
852
|
+
if (done.length > 0) {
|
|
853
|
+
imported.done = done;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const approvalsSection = getSectionText(sections, ["approvals", "approval"]);
|
|
857
|
+
if (approvalsSection) {
|
|
858
|
+
const mode = approvalsSection.match(/(?:mode|policy)\s*:\s*`?([a-z0-9_-]+)`?/i);
|
|
859
|
+
if (mode && mode[1]) {
|
|
860
|
+
imported.approvals.mode = mode[1].trim();
|
|
861
|
+
}
|
|
862
|
+
const approvalNotes = parseListItems(approvalsSection);
|
|
863
|
+
if (approvalNotes.length > 0) {
|
|
864
|
+
imported.approvals.notes = approvalNotes;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
imported.commands = importCommandsFromText(source.text, imported.commands);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
for (const adapter of ADAPTERS) {
|
|
872
|
+
const config = getTargetConfig(imported, adapter);
|
|
873
|
+
const absolutePath = resolveInRoot(rootDir, config.path);
|
|
874
|
+
if (existsSync(absolutePath)) {
|
|
875
|
+
imported.targets[adapter.id].enabled = true;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
notes.push("Imported mission/workflow/commands/done/approvals where detected.");
|
|
880
|
+
return {
|
|
881
|
+
rules: imported,
|
|
882
|
+
report: notes.join("\n"),
|
|
883
|
+
sources,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function initCommand(rootDir, logger, args) {
|
|
888
|
+
const options = parseInitArgs(args);
|
|
512
889
|
const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
|
|
513
890
|
if (existsSync(rulesFile)) {
|
|
514
891
|
logger.log(`rules.yaml already exists: ${rulesFile}`);
|
|
515
|
-
return;
|
|
892
|
+
return 0;
|
|
516
893
|
}
|
|
517
894
|
|
|
518
895
|
const defaults = createDefaultRules(loadPackageScripts(rootDir));
|
|
896
|
+
let rules = defaults;
|
|
897
|
+
let importReport = "";
|
|
898
|
+
|
|
899
|
+
if (options.importExisting) {
|
|
900
|
+
const imported = importRulesFromDocs(rootDir, defaults);
|
|
901
|
+
rules = imported.rules;
|
|
902
|
+
importReport = imported.report;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
rules = applyTargetPreset(rules, options.preset);
|
|
906
|
+
|
|
519
907
|
ensureParentDirectory(rulesFile);
|
|
520
|
-
writeFileSync(rulesFile, stringifyRules(
|
|
908
|
+
writeFileSync(rulesFile, stringifyRules(rules), "utf8");
|
|
521
909
|
logger.log(`Created ${rulesFile}`);
|
|
910
|
+
logger.log(`Applied target preset: ${options.preset}`);
|
|
911
|
+
|
|
912
|
+
if (options.importExisting) {
|
|
913
|
+
const reportPath = resolve(rootDir, IMPORT_REPORT_RELATIVE_PATH);
|
|
914
|
+
const reportContent =
|
|
915
|
+
options.preset === "all"
|
|
916
|
+
? `${importReport}\n`
|
|
917
|
+
: `${importReport}\n\nApplied target preset: ${options.preset}\n`;
|
|
918
|
+
writeFileSync(reportPath, reportContent, "utf8");
|
|
919
|
+
logger.log(`Import report: ${reportPath}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return 0;
|
|
522
923
|
}
|
|
523
924
|
|
|
524
|
-
function
|
|
525
|
-
const
|
|
526
|
-
const
|
|
925
|
+
function presetApplyCommand(rootDir, logger, args) {
|
|
926
|
+
const options = parsePresetArgs(args);
|
|
927
|
+
const { rules, rulesFile } = loadRules(rootDir);
|
|
928
|
+
const nextRules = applyTargetPreset(
|
|
929
|
+
{
|
|
930
|
+
...rules,
|
|
931
|
+
targets: { ...(rules.targets || {}) },
|
|
932
|
+
},
|
|
933
|
+
options.presetName,
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
const currentText = stringifyRules(rules);
|
|
937
|
+
const nextText = stringifyRules(nextRules);
|
|
938
|
+
const changed = currentText !== nextText;
|
|
527
939
|
|
|
528
|
-
|
|
529
|
-
|
|
940
|
+
logger.log("rules-doctor preset apply");
|
|
941
|
+
logger.log(`- root: ${rootDir}`);
|
|
942
|
+
logger.log(`- preset: ${options.presetName}`);
|
|
943
|
+
logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
|
|
944
|
+
|
|
945
|
+
if (options.diff && changed) {
|
|
946
|
+
logger.log("\n# diff: .agentrules/rules.yaml");
|
|
947
|
+
logger.log(renderSimpleDiff(currentText, nextText));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (!changed) {
|
|
951
|
+
logger.log("No changes required: preset already applied.");
|
|
952
|
+
return 0;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (!options.write) {
|
|
956
|
+
logger.log("Dry-run complete: rules.yaml would be updated. Re-run with --write.");
|
|
957
|
+
return 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
ensureParentDirectory(rulesFile);
|
|
961
|
+
writeFileSync(rulesFile, nextText, "utf8");
|
|
962
|
+
logger.log(`Updated ${rulesFile}`);
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function buildTargetPlans(rootDir, rules, targetIds) {
|
|
967
|
+
const plans = [];
|
|
968
|
+
|
|
969
|
+
for (const targetId of targetIds) {
|
|
530
970
|
const adapter = ADAPTERS_BY_ID[targetId];
|
|
531
971
|
const target = getTargetConfig(rules, adapter);
|
|
972
|
+
const targetPath = resolveInRoot(rootDir, target.path);
|
|
973
|
+
const fileExists = existsSync(targetPath);
|
|
974
|
+
const currentText = fileExists ? readFileSync(targetPath, "utf8") : "";
|
|
532
975
|
|
|
533
976
|
if (!target.enabled) {
|
|
534
|
-
|
|
977
|
+
plans.push({
|
|
978
|
+
targetId,
|
|
979
|
+
adapter,
|
|
980
|
+
enabled: false,
|
|
981
|
+
targetPath,
|
|
982
|
+
targetPathDisplay: target.path,
|
|
983
|
+
exists: fileExists,
|
|
984
|
+
currentText,
|
|
985
|
+
desiredText: currentText,
|
|
986
|
+
changed: false,
|
|
987
|
+
});
|
|
535
988
|
continue;
|
|
536
989
|
}
|
|
537
990
|
|
|
538
|
-
const targetPath = resolveInRoot(rootDir, target.path);
|
|
539
991
|
const rendered = adapter.render(rules).trim();
|
|
540
|
-
|
|
992
|
+
const desiredText =
|
|
993
|
+
adapter.management === "marker"
|
|
994
|
+
? upsertManagedSection(currentText, rendered, adapter.markerBegin, adapter.markerEnd)
|
|
995
|
+
: `${rendered}\n`;
|
|
996
|
+
|
|
997
|
+
plans.push({
|
|
998
|
+
targetId,
|
|
999
|
+
adapter,
|
|
1000
|
+
enabled: true,
|
|
1001
|
+
targetPath,
|
|
1002
|
+
targetPathDisplay: target.path,
|
|
1003
|
+
exists: fileExists,
|
|
1004
|
+
currentText,
|
|
1005
|
+
desiredText,
|
|
1006
|
+
changed: desiredText !== currentText,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
541
1009
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1010
|
+
return plans;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function renderSimpleDiff(currentText, desiredText) {
|
|
1014
|
+
if (currentText === desiredText) {
|
|
1015
|
+
return "";
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const oldLines = currentText.replace(/\r\n/g, "\n").split("\n");
|
|
1019
|
+
const newLines = desiredText.replace(/\r\n/g, "\n").split("\n");
|
|
1020
|
+
const output = ["--- current", "+++ desired"];
|
|
1021
|
+
const maxLines = Math.max(oldLines.length, newLines.length);
|
|
1022
|
+
const hardLimit = 120;
|
|
1023
|
+
let emitted = 0;
|
|
1024
|
+
|
|
1025
|
+
for (let index = 0; index < maxLines; index += 1) {
|
|
1026
|
+
const oldLine = oldLines[index];
|
|
1027
|
+
const newLine = newLines[index];
|
|
1028
|
+
|
|
1029
|
+
if (oldLine === newLine) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (typeof oldLine !== "undefined") {
|
|
1034
|
+
output.push(`-${oldLine}`);
|
|
1035
|
+
emitted += 1;
|
|
1036
|
+
}
|
|
1037
|
+
if (typeof newLine !== "undefined") {
|
|
1038
|
+
output.push(`+${newLine}`);
|
|
1039
|
+
emitted += 1;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (emitted >= hardLimit) {
|
|
1043
|
+
output.push("... diff truncated ...");
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return output.join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function formatPlanSummary(plans) {
|
|
1052
|
+
const changed = plans.filter((plan) => plan.changed).length;
|
|
1053
|
+
const changedFiles = new Set(plans.filter((plan) => plan.changed).map((plan) => plan.targetPath)).size;
|
|
1054
|
+
const enabled = plans.filter((plan) => plan.enabled).length;
|
|
1055
|
+
const disabled = plans.length - enabled;
|
|
1056
|
+
return { changed, changedFiles, enabled, disabled };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function getUniqueWritePlans(plans) {
|
|
1060
|
+
const byPath = new Map();
|
|
1061
|
+
const duplicates = [];
|
|
1062
|
+
|
|
1063
|
+
for (const plan of plans) {
|
|
1064
|
+
if (!plan.enabled || !plan.changed) {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const existing = byPath.get(plan.targetPath);
|
|
1069
|
+
if (!existing) {
|
|
1070
|
+
byPath.set(plan.targetPath, plan);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (existing.desiredText !== plan.desiredText) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
`Conflicting outputs for ${plan.targetPathDisplay}: ${existing.targetId} and ${plan.targetId} produce different content.`,
|
|
549
1077
|
);
|
|
550
|
-
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
duplicates.push({
|
|
1081
|
+
targetPathDisplay: plan.targetPathDisplay,
|
|
1082
|
+
winner: existing.targetId,
|
|
1083
|
+
duplicate: plan.targetId,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return {
|
|
1088
|
+
uniquePlans: Array.from(byPath.values()),
|
|
1089
|
+
duplicates,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function syncCommand(rootDir, logger, args) {
|
|
1094
|
+
const options = parseTargetedArgs("sync", args, { write: true, backup: true });
|
|
1095
|
+
const { rules } = loadRules(rootDir);
|
|
1096
|
+
const plans = buildTargetPlans(rootDir, rules, options.targetIds);
|
|
1097
|
+
const summary = formatPlanSummary(plans);
|
|
1098
|
+
|
|
1099
|
+
logger.log("rules-doctor sync");
|
|
1100
|
+
logger.log(`- root: ${rootDir}`);
|
|
1101
|
+
logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
|
|
1102
|
+
logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
|
|
1103
|
+
|
|
1104
|
+
for (const plan of plans) {
|
|
1105
|
+
if (!plan.enabled) {
|
|
1106
|
+
logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (!plan.changed) {
|
|
1110
|
+
logger.log(`- ${plan.targetId}: up-to-date (${plan.targetPathDisplay})`);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
logger.log(`- ${plan.targetId}: would update (${plan.targetPathDisplay})`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (options.diff) {
|
|
1117
|
+
for (const plan of plans) {
|
|
1118
|
+
if (!plan.enabled || !plan.changed) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
|
|
1122
|
+
logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!options.write) {
|
|
1127
|
+
if (summary.changed === 0) {
|
|
1128
|
+
logger.log("Dry-run complete: no changes.");
|
|
551
1129
|
} else {
|
|
552
|
-
|
|
1130
|
+
logger.log(
|
|
1131
|
+
`Dry-run complete: ${summary.changedFiles} file(s) would change (${summary.changed} target mappings). Re-run with --write.`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const { uniquePlans, duplicates } = getUniqueWritePlans(plans);
|
|
1138
|
+
for (const duplicate of duplicates) {
|
|
1139
|
+
logger.log(
|
|
1140
|
+
` note: ${duplicate.duplicate} shares output with ${duplicate.winner} at ${duplicate.targetPathDisplay}`,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1145
|
+
for (const plan of uniquePlans) {
|
|
1146
|
+
ensureParentDirectory(plan.targetPath);
|
|
1147
|
+
if (options.backup && plan.exists) {
|
|
1148
|
+
const backupPath = `${plan.targetPath}.rules-doctor.bak.${timestamp}`;
|
|
1149
|
+
writeFileSync(backupPath, plan.currentText, "utf8");
|
|
1150
|
+
logger.log(` backup: ${backupPath}`);
|
|
553
1151
|
}
|
|
554
1152
|
|
|
555
|
-
|
|
556
|
-
updated
|
|
1153
|
+
writeFileSync(plan.targetPath, plan.desiredText, "utf8");
|
|
1154
|
+
logger.log(` updated: ${plan.targetPath}`);
|
|
557
1155
|
}
|
|
558
1156
|
|
|
559
|
-
|
|
560
|
-
|
|
1157
|
+
logger.log(
|
|
1158
|
+
`Write complete: ${uniquePlans.length} file(s) updated (${summary.changed} target mappings changed).`,
|
|
1159
|
+
);
|
|
1160
|
+
return 0;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function checkCommand(rootDir, logger, args) {
|
|
1164
|
+
const options = parseTargetedArgs("check", args, { write: false, backup: false });
|
|
1165
|
+
const { rules } = loadRules(rootDir);
|
|
1166
|
+
const plans = buildTargetPlans(rootDir, rules, options.targetIds);
|
|
1167
|
+
const summary = formatPlanSummary(plans);
|
|
1168
|
+
|
|
1169
|
+
logger.log("rules-doctor check");
|
|
1170
|
+
logger.log(`- root: ${rootDir}`);
|
|
1171
|
+
logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
|
|
1172
|
+
|
|
1173
|
+
for (const plan of plans) {
|
|
1174
|
+
if (!plan.enabled) {
|
|
1175
|
+
logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
logger.log(
|
|
1179
|
+
`- ${plan.targetId}: ${plan.changed ? "drift detected" : "in sync"} (${plan.targetPathDisplay})`,
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (options.diff) {
|
|
1184
|
+
for (const plan of plans) {
|
|
1185
|
+
if (!plan.enabled || !plan.changed) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
|
|
1189
|
+
logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
|
|
1190
|
+
}
|
|
561
1191
|
}
|
|
1192
|
+
|
|
1193
|
+
if (summary.changed === 0) {
|
|
1194
|
+
logger.log("Check complete: all selected targets are in sync.");
|
|
1195
|
+
return 0;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
logger.log(`Check failed: ${summary.changed} target file(s) need sync.`);
|
|
1199
|
+
return 1;
|
|
562
1200
|
}
|
|
563
1201
|
|
|
564
1202
|
function analyzeCommand(rootDir, logger, args) {
|
|
@@ -653,6 +1291,57 @@ function analyzeCommand(rootDir, logger, args) {
|
|
|
653
1291
|
return 0;
|
|
654
1292
|
}
|
|
655
1293
|
|
|
1294
|
+
function doctorCommand(rootDir, logger, args) {
|
|
1295
|
+
const options = parseDoctorArgs(args);
|
|
1296
|
+
const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
|
|
1297
|
+
const issues = [];
|
|
1298
|
+
|
|
1299
|
+
logger.log("rules-doctor doctor");
|
|
1300
|
+
logger.log(`- root: ${rootDir}`);
|
|
1301
|
+
logger.log(`- rules file: ${rulesFile}`);
|
|
1302
|
+
logger.log(`- rules exists: ${rulesExists ? "yes" : "no (defaults assumed)"}`);
|
|
1303
|
+
|
|
1304
|
+
const pathToTargets = {};
|
|
1305
|
+
for (const adapter of ADAPTERS) {
|
|
1306
|
+
const target = getTargetConfig(rules, adapter);
|
|
1307
|
+
const absolutePath = resolveInRoot(rootDir, target.path);
|
|
1308
|
+
const exists = existsSync(absolutePath);
|
|
1309
|
+
const enabledText = target.enabled ? "enabled" : "disabled";
|
|
1310
|
+
logger.log(`- ${adapter.id}: ${enabledText}, path=${target.path}, file=${exists ? "found" : "missing"}`);
|
|
1311
|
+
|
|
1312
|
+
if (!target.enabled) {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const key = absolutePath;
|
|
1317
|
+
if (!pathToTargets[key]) {
|
|
1318
|
+
pathToTargets[key] = [];
|
|
1319
|
+
}
|
|
1320
|
+
pathToTargets[key].push(adapter.id);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
for (const path of Object.keys(pathToTargets)) {
|
|
1324
|
+
const ids = pathToTargets[path];
|
|
1325
|
+
if (ids.length > 1) {
|
|
1326
|
+
issues.push(`Multiple enabled targets map to the same file: ${ids.join(", ")} -> ${path}`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
logger.log("- Findings:");
|
|
1331
|
+
if (issues.length === 0) {
|
|
1332
|
+
logger.log("- No structural issues found.");
|
|
1333
|
+
return 0;
|
|
1334
|
+
}
|
|
1335
|
+
for (const issue of issues) {
|
|
1336
|
+
logger.log(`- ${issue}`);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (options.strict) {
|
|
1340
|
+
return 1;
|
|
1341
|
+
}
|
|
1342
|
+
return 0;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
656
1345
|
function targetsListCommand(logger) {
|
|
657
1346
|
logger.log("Supported targets:");
|
|
658
1347
|
for (const adapter of ADAPTERS) {
|
|
@@ -679,19 +1368,29 @@ function runCli(argv, options) {
|
|
|
679
1368
|
}
|
|
680
1369
|
|
|
681
1370
|
if (command === "init") {
|
|
682
|
-
initCommand(rootDir, logger);
|
|
683
|
-
|
|
1371
|
+
return initCommand(rootDir, logger, rest);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (command === "preset") {
|
|
1375
|
+
return presetApplyCommand(rootDir, logger, rest);
|
|
684
1376
|
}
|
|
685
1377
|
|
|
686
1378
|
if (command === "sync") {
|
|
687
|
-
syncCommand(rootDir, logger, rest);
|
|
688
|
-
|
|
1379
|
+
return syncCommand(rootDir, logger, rest);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (command === "check") {
|
|
1383
|
+
return checkCommand(rootDir, logger, rest);
|
|
689
1384
|
}
|
|
690
1385
|
|
|
691
1386
|
if (command === "analyze") {
|
|
692
1387
|
return analyzeCommand(rootDir, logger, rest);
|
|
693
1388
|
}
|
|
694
1389
|
|
|
1390
|
+
if (command === "doctor") {
|
|
1391
|
+
return doctorCommand(rootDir, logger, rest);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
695
1394
|
if (command === "targets") {
|
|
696
1395
|
if (rest.length === 1 && rest[0] === "list") {
|
|
697
1396
|
targetsListCommand(logger);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jazpiper/rules-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Node.js CLI to sync shared coding rules across multiple agent/CLI targets",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "node scripts/tsup.js src/index.js --clean --dts",
|
|
18
|
-
"test": "npm run build && node --test"
|
|
19
|
+
"test": "npm run build && node --test",
|
|
20
|
+
"release:patch": "bash scripts/release.sh patch",
|
|
21
|
+
"release:minor": "bash scripts/release.sh minor",
|
|
22
|
+
"release:major": "bash scripts/release.sh major"
|
|
19
23
|
},
|
|
20
24
|
"keywords": [
|
|
21
25
|
"cli",
|
|
@@ -23,10 +27,10 @@
|
|
|
23
27
|
"rules",
|
|
24
28
|
"claude",
|
|
25
29
|
"codex",
|
|
30
|
+
"copilot",
|
|
26
31
|
"cursor",
|
|
27
32
|
"gemini",
|
|
28
|
-
"opencode"
|
|
29
|
-
"antigravity"
|
|
33
|
+
"opencode"
|
|
30
34
|
],
|
|
31
35
|
"engines": {
|
|
32
36
|
"node": ">=18"
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
const { renderManagedRulesBody } = require("./common");
|
|
2
|
-
|
|
3
|
-
module.exports = {
|
|
4
|
-
id: "antigravity",
|
|
5
|
-
name: "Antigravity CLI",
|
|
6
|
-
description: "Generate GEMINI.md-compatible managed instruction file (inferred mapping).",
|
|
7
|
-
defaultPath: "GEMINI.md",
|
|
8
|
-
management: "full",
|
|
9
|
-
render(rules) {
|
|
10
|
-
return ["# GEMINI.md", "", renderManagedRulesBody(rules)].join("\n");
|
|
11
|
-
},
|
|
12
|
-
};
|