@jazpiper/rules-doctor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @jazpiper/rules-doctor
2
2
 
3
- `rules-doctor` is a Node.js CLI (TypeScript) that keeps agent instruction files in sync from one source of truth: `.agentrules/rules.yaml`.
3
+ `rules-doctor` is a Node.js CLI that keeps agent instruction files in sync from one source of truth: `.agentrules/rules.yaml`.
4
+
5
+ It is designed for multi-agent workflows where each coding CLI reads a different file format.
6
+
7
+ By default, commands resolve paths relative to the project root (`.git` ancestor), not the current subdirectory.
4
8
 
5
9
  ## Install
6
10
 
@@ -36,11 +40,15 @@ Creates `.agentrules/rules.yaml` if it does not exist.
36
40
  rules-doctor sync
37
41
  rules-doctor sync --target claude
38
42
  rules-doctor sync --target codex
43
+ rules-doctor sync --target cursor,gemini,opencode,antigravity
39
44
  ```
40
45
 
41
46
  Generates/updates:
42
- - `CLAUDE.md` (fully managed)
43
- - `AGENTS.md` (only content inside markers is managed)
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)
44
52
 
45
53
  Managed marker block in `AGENTS.md`:
46
54
 
@@ -58,10 +66,74 @@ If markers are missing, a new managed block is appended to the end of `AGENTS.md
58
66
  rules-doctor analyze
59
67
  ```
60
68
 
61
- Reads `CLAUDE.md` and `AGENTS.md`, then prints a concise report about:
69
+ Reads enabled target files from `rules.yaml`, then prints a concise report about:
62
70
  - missing markers
63
71
  - missing verify commands (`lint`/`test`/`build`)
64
- - obvious contradictions (simple heuristics)
72
+ - obvious contradictions (simple heuristics across generated targets)
73
+
74
+ `--strict` makes `analyze` fail with non-zero exit code when findings exist.
75
+
76
+ ### 4) List supported adapters
77
+
78
+ ```bash
79
+ rules-doctor targets list
80
+ ```
81
+
82
+ Shows built-in adapters and default file paths.
83
+
84
+ ### 5) Directory/Path Notes
85
+
86
+ - `claude`: searches `CLAUDE.md` from current directory upward; also supports `.claude/CLAUDE.md` and `.claude/rules/*.md`.
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.
92
+
93
+ ## Rules Schema (v2 Draft)
94
+
95
+ `init` now creates a v2-compatible draft with `targets` configuration:
96
+
97
+ ```yaml
98
+ version: 2
99
+ mission: "Ship safe changes quickly while keeping agent instructions consistent."
100
+ workflow:
101
+ - "Read relevant files before editing."
102
+ - "Make the smallest correct change."
103
+ - "Run verification commands before finalizing."
104
+ commands:
105
+ lint: "npm run lint"
106
+ test: "npm run test"
107
+ build: "npm run build"
108
+ done:
109
+ - "Commands pass or blockers are documented."
110
+ - "Changed behavior is reflected in docs where needed."
111
+ approvals:
112
+ mode: "ask-before-destructive"
113
+ notes:
114
+ - "Ask before destructive actions or privileged operations."
115
+ targets:
116
+ claude:
117
+ enabled: true
118
+ path: "CLAUDE.md"
119
+ codex:
120
+ enabled: true
121
+ path: "AGENTS.md"
122
+ cursor:
123
+ enabled: true
124
+ path: ".cursor/rules/rules-doctor.mdc"
125
+ gemini:
126
+ enabled: true
127
+ path: "GEMINI.md"
128
+ opencode:
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`.
65
137
 
66
138
  ## Development
67
139
 
@@ -0,0 +1,12 @@
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
+ };
@@ -0,0 +1,31 @@
1
+ const { formatCommands, formatList } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "claude",
5
+ name: "Claude Code",
6
+ description: "Generate CLAUDE.md from rules.yaml.",
7
+ defaultPath: "CLAUDE.md",
8
+ management: "full",
9
+ render(rules) {
10
+ return [
11
+ "# CLAUDE.md",
12
+ "",
13
+ "## Mission",
14
+ rules.mission,
15
+ "",
16
+ "## Workflow",
17
+ formatList(rules.workflow),
18
+ "",
19
+ "## Commands",
20
+ formatCommands(rules.commands),
21
+ "",
22
+ "## Done",
23
+ formatList(rules.done),
24
+ "",
25
+ "## Approvals",
26
+ `- Mode: \`${rules.approvals.mode}\``,
27
+ ...rules.approvals.notes.map((note) => `- ${note}`),
28
+ "",
29
+ ].join("\n");
30
+ },
31
+ };
@@ -0,0 +1,14 @@
1
+ const { renderManagedRulesBody } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "codex",
5
+ name: "Codex CLI",
6
+ description: "Manage AGENTS.md via marker-managed section.",
7
+ defaultPath: "AGENTS.md",
8
+ management: "marker",
9
+ markerBegin: "<!-- RULES_DOCTOR:BEGIN -->",
10
+ markerEnd: "<!-- RULES_DOCTOR:END -->",
11
+ render(rules) {
12
+ return renderManagedRulesBody(rules);
13
+ },
14
+ };
@@ -0,0 +1,54 @@
1
+ function formatList(items) {
2
+ if (!Array.isArray(items) || items.length === 0) {
3
+ return "- (none)";
4
+ }
5
+ return items.map((item) => `- ${item}`).join("\n");
6
+ }
7
+
8
+ function formatCommands(commands) {
9
+ if (!commands || typeof commands !== "object") {
10
+ return "- (none)";
11
+ }
12
+
13
+ const preferredOrder = ["lint", "test", "build"];
14
+ const names = [
15
+ ...preferredOrder.filter((name) => Object.prototype.hasOwnProperty.call(commands, name)),
16
+ ...Object.keys(commands).filter((name) => !preferredOrder.includes(name)),
17
+ ];
18
+
19
+ if (names.length === 0) {
20
+ return "- (none)";
21
+ }
22
+
23
+ return names.map((name) => `- ${name}: \`${commands[name]}\``).join("\n");
24
+ }
25
+
26
+ function renderManagedRulesBody(rules) {
27
+ return [
28
+ "## rules-doctor Managed Rules",
29
+ "Generated from `.agentrules/rules.yaml`. Edit that file, then run `rules-doctor sync`.",
30
+ "",
31
+ "### Mission",
32
+ rules.mission,
33
+ "",
34
+ "### Workflow",
35
+ formatList(rules.workflow),
36
+ "",
37
+ "### Commands",
38
+ formatCommands(rules.commands),
39
+ "",
40
+ "### Done",
41
+ formatList(rules.done),
42
+ "",
43
+ "### Approvals",
44
+ `- Policy: \`${rules.approvals.mode}\``,
45
+ ...rules.approvals.notes.map((note) => `- ${note}`),
46
+ "",
47
+ ].join("\n");
48
+ }
49
+
50
+ module.exports = {
51
+ formatCommands,
52
+ formatList,
53
+ renderManagedRulesBody,
54
+ };
@@ -0,0 +1,19 @@
1
+ const { renderManagedRulesBody } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "cursor",
5
+ name: "Cursor",
6
+ description: "Manage .cursor/rules/rules-doctor.mdc as an always-applied project rule.",
7
+ defaultPath: ".cursor/rules/rules-doctor.mdc",
8
+ management: "full",
9
+ render(rules) {
10
+ return [
11
+ "---",
12
+ "description: rules-doctor managed coding rules",
13
+ "alwaysApply: true",
14
+ "---",
15
+ "",
16
+ renderManagedRulesBody(rules),
17
+ ].join("\n");
18
+ },
19
+ };
@@ -0,0 +1,12 @@
1
+ const { renderManagedRulesBody } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "gemini",
5
+ name: "Gemini CLI",
6
+ description: "Generate GEMINI.md managed instruction file.",
7
+ defaultPath: "GEMINI.md",
8
+ management: "full",
9
+ render(rules) {
10
+ return ["# GEMINI.md", "", renderManagedRulesBody(rules)].join("\n");
11
+ },
12
+ };
@@ -0,0 +1,14 @@
1
+ const claude = require("./claude");
2
+ const codex = require("./codex");
3
+ const cursor = require("./cursor");
4
+ const gemini = require("./gemini");
5
+ const opencode = require("./opencode");
6
+ const antigravity = require("./antigravity");
7
+
8
+ const ADAPTERS = [claude, codex, cursor, gemini, opencode, antigravity];
9
+ const ADAPTERS_BY_ID = Object.fromEntries(ADAPTERS.map((adapter) => [adapter.id, adapter]));
10
+
11
+ module.exports = {
12
+ ADAPTERS,
13
+ ADAPTERS_BY_ID,
14
+ };
@@ -0,0 +1,14 @@
1
+ const { renderManagedRulesBody } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "opencode",
5
+ name: "OpenCode CLI",
6
+ description: "Manage AGENTS.md via marker-managed section (OpenCode rules).",
7
+ defaultPath: "AGENTS.md",
8
+ management: "marker",
9
+ markerBegin: "<!-- RULES_DOCTOR:BEGIN -->",
10
+ markerEnd: "<!-- RULES_DOCTOR:END -->",
11
+ render(rules) {
12
+ return renderManagedRulesBody(rules);
13
+ },
14
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js CHANGED
@@ -1,32 +1,43 @@
1
1
  #!/usr/bin/env node
2
+ const { dirname, isAbsolute, resolve } = require("node:path");
2
3
  const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
3
- const { resolve } = require("node:path");
4
+ const { ADAPTERS, ADAPTERS_BY_ID } = require("./adapters");
4
5
 
5
- const RULES_FILE = resolve(".agentrules/rules.yaml");
6
- const CLAUDE_FILE = resolve("CLAUDE.md");
7
- const AGENTS_FILE = resolve("AGENTS.md");
8
- const MARKER_BEGIN = "<!-- RULES_DOCTOR:BEGIN -->";
9
- const MARKER_END = "<!-- RULES_DOCTOR:END -->";
6
+ const RULES_RELATIVE_PATH = ".agentrules/rules.yaml";
10
7
 
11
8
  function usage() {
9
+ const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
12
10
  return [
13
11
  "rules-doctor",
14
12
  "",
15
13
  "Usage:",
16
14
  " rules-doctor init",
17
- " rules-doctor sync [--target all|claude|codex]",
15
+ ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>]`,
18
16
  " rules-doctor analyze",
17
+ " rules-doctor targets list",
19
18
  ].join("\n");
20
19
  }
21
20
 
21
+ function createLogger(options) {
22
+ return {
23
+ log:
24
+ options && typeof options.stdout === "function"
25
+ ? options.stdout
26
+ : (message) => console.log(message),
27
+ error:
28
+ options && typeof options.stderr === "function"
29
+ ? options.stderr
30
+ : (message) => console.error(message),
31
+ };
32
+ }
33
+
22
34
  function readJsonFile(filePath) {
23
35
  if (!existsSync(filePath)) {
24
36
  return null;
25
37
  }
26
38
 
27
39
  try {
28
- const raw = readFileSync(filePath, "utf8");
29
- return JSON.parse(raw);
40
+ return JSON.parse(readFileSync(filePath, "utf8"));
30
41
  } catch {
31
42
  return null;
32
43
  }
@@ -49,6 +60,12 @@ function stripQuotes(value) {
49
60
 
50
61
  function parseScalar(value) {
51
62
  const cleaned = stripQuotes(value);
63
+ if (/^(true|false)$/i.test(cleaned)) {
64
+ return cleaned.toLowerCase() === "true";
65
+ }
66
+ if (cleaned === "null" || cleaned === "~") {
67
+ return null;
68
+ }
52
69
  if (/^-?\d+$/.test(cleaned)) {
53
70
  return Number(cleaned);
54
71
  }
@@ -70,6 +87,7 @@ function parseRulesText(text) {
70
87
  const lines = text.replace(/\r\n/g, "\n").split("\n");
71
88
  let section = null;
72
89
  let nested = null;
90
+ let currentTarget = null;
73
91
 
74
92
  for (const rawLine of lines) {
75
93
  if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
@@ -81,6 +99,8 @@ function parseRulesText(text) {
81
99
 
82
100
  if (indent === 0) {
83
101
  nested = null;
102
+ currentTarget = null;
103
+
84
104
  const top = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
85
105
  if (!top) {
86
106
  continue;
@@ -88,11 +108,12 @@ function parseRulesText(text) {
88
108
 
89
109
  const key = top[1];
90
110
  const value = top[2].trim();
111
+
91
112
  if (!value) {
92
113
  section = key;
93
114
  if (key === "workflow" || key === "done") {
94
115
  data[key] = [];
95
- } else if (key === "commands" || key === "approvals") {
116
+ } else if (key === "commands" || key === "approvals" || key === "targets") {
96
117
  data[key] = {};
97
118
  }
98
119
  } else {
@@ -132,6 +153,35 @@ function parseRulesText(text) {
132
153
  if (nested === "notes" && line.startsWith("- ")) {
133
154
  data.approvals.notes.push(parseScalar(line.slice(2)));
134
155
  }
156
+ continue;
157
+ }
158
+
159
+ if (section === "targets") {
160
+ if (indent === 2) {
161
+ const target = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
162
+ if (!target) {
163
+ continue;
164
+ }
165
+
166
+ currentTarget = target[1];
167
+ const maybeValue = target[2].trim();
168
+ if (!maybeValue) {
169
+ data.targets[currentTarget] = {};
170
+ } else {
171
+ data.targets[currentTarget] = { path: parseScalar(maybeValue), enabled: true };
172
+ }
173
+ continue;
174
+ }
175
+
176
+ if (indent >= 4 && currentTarget) {
177
+ const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
178
+ if (pair) {
179
+ if (!data.targets[currentTarget] || typeof data.targets[currentTarget] !== "object") {
180
+ data.targets[currentTarget] = {};
181
+ }
182
+ data.targets[currentTarget][pair[1]] = parseScalar(pair[2].trim());
183
+ }
184
+ }
135
185
  }
136
186
  }
137
187
 
@@ -139,31 +189,15 @@ function parseRulesText(text) {
139
189
  }
140
190
 
141
191
  function quoteYaml(value) {
192
+ if (typeof value === "number" || typeof value === "boolean") {
193
+ return String(value);
194
+ }
195
+ if (value === null || typeof value === "undefined") {
196
+ return "null";
197
+ }
142
198
  return JSON.stringify(String(value));
143
199
  }
144
200
 
145
- function stringifyRules(rules) {
146
- const lines = [
147
- `version: ${Number.isFinite(rules.version) ? rules.version : 1}`,
148
- `mission: ${quoteYaml(rules.mission)}`,
149
- "workflow:",
150
- ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
151
- "commands:",
152
- ...Object.keys(rules.commands).map(
153
- (name) => ` ${name}: ${quoteYaml(rules.commands[name])}`,
154
- ),
155
- "done:",
156
- ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
157
- "approvals:",
158
- ` mode: ${quoteYaml(rules.approvals.mode)}`,
159
- " notes:",
160
- ...rules.approvals.notes.map((note) => ` - ${quoteYaml(note)}`),
161
- "",
162
- ];
163
-
164
- return lines.join("\n");
165
- }
166
-
167
201
  function inferCommandFromScripts(scripts, scriptName) {
168
202
  if (scripts && typeof scripts[scriptName] === "string") {
169
203
  return `npm run ${scriptName}`;
@@ -171,12 +205,17 @@ function inferCommandFromScripts(scripts, scriptName) {
171
205
  return `echo "TODO: define ${scriptName} command"`;
172
206
  }
173
207
 
174
- function createDefaultRules() {
175
- const pkg = readJsonFile(resolve("package.json"));
176
- const scripts = pkg && typeof pkg === "object" ? pkg.scripts : undefined;
208
+ function createDefaultRules(scripts) {
209
+ const targets = {};
210
+ for (const adapter of ADAPTERS) {
211
+ targets[adapter.id] = {
212
+ enabled: true,
213
+ path: adapter.defaultPath,
214
+ };
215
+ }
177
216
 
178
217
  return {
179
- version: 1,
218
+ version: 2,
180
219
  mission: "Ship safe changes quickly while keeping agent instructions consistent.",
181
220
  workflow: [
182
221
  "Read relevant files before editing.",
@@ -196,322 +235,488 @@ function createDefaultRules() {
196
235
  mode: "ask-before-destructive",
197
236
  notes: ["Ask before destructive actions or privileged operations."],
198
237
  },
238
+ targets,
239
+ };
240
+ }
241
+
242
+ function normalizeTargetConfig(source, fallbackPath) {
243
+ if (typeof source === "string" && source.trim()) {
244
+ return { enabled: true, path: source.trim() };
245
+ }
246
+
247
+ if (!source || typeof source !== "object") {
248
+ return { enabled: true, path: fallbackPath };
249
+ }
250
+
251
+ return {
252
+ enabled: typeof source.enabled === "boolean" ? source.enabled : true,
253
+ path: typeof source.path === "string" && source.path.trim() ? source.path.trim() : fallbackPath,
199
254
  };
200
255
  }
201
256
 
202
- function normalizeRules(input) {
257
+ function normalizeRules(input, defaults) {
203
258
  const source = input && typeof input === "object" ? input : {};
204
259
  const commands = source.commands && typeof source.commands === "object" ? source.commands : {};
205
260
  const approvals =
206
261
  source.approvals && typeof source.approvals === "object" ? source.approvals : {};
262
+ const sourceTargets = source.targets && typeof source.targets === "object" ? source.targets : {};
207
263
 
208
264
  const workflow = Array.isArray(source.workflow)
209
265
  ? source.workflow.filter((item) => typeof item === "string")
210
- : ["Define your workflow steps."];
266
+ : defaults.workflow;
211
267
 
212
268
  const done = Array.isArray(source.done)
213
269
  ? source.done.filter((item) => typeof item === "string")
214
- : ["Define done criteria."];
270
+ : defaults.done;
215
271
 
216
272
  const notes = Array.isArray(approvals.notes)
217
273
  ? approvals.notes.filter((item) => typeof item === "string")
218
- : [];
274
+ : defaults.approvals.notes;
275
+
276
+ const targets = {};
277
+ for (const adapter of ADAPTERS) {
278
+ targets[adapter.id] = normalizeTargetConfig(sourceTargets[adapter.id], defaults.targets[adapter.id].path);
279
+ }
280
+
281
+ for (const customId of Object.keys(sourceTargets)) {
282
+ if (targets[customId]) {
283
+ continue;
284
+ }
285
+ targets[customId] = normalizeTargetConfig(sourceTargets[customId], `${customId.toUpperCase()}.md`);
286
+ }
219
287
 
220
288
  return {
221
- version: typeof source.version === "number" ? source.version : 1,
289
+ version: typeof source.version === "number" ? source.version : defaults.version,
222
290
  mission:
223
- typeof source.mission === "string" && source.mission.trim()
224
- ? source.mission
225
- : "Define your project mission.",
226
- workflow,
291
+ typeof source.mission === "string" && source.mission.trim() ? source.mission : defaults.mission,
292
+ workflow: workflow.length > 0 ? workflow : defaults.workflow,
227
293
  commands: {
228
- lint:
229
- typeof commands.lint === "string"
230
- ? commands.lint
231
- : 'echo "TODO: define lint command"',
232
- test:
233
- typeof commands.test === "string"
234
- ? commands.test
235
- : 'echo "TODO: define test command"',
236
- build:
237
- typeof commands.build === "string"
238
- ? commands.build
239
- : 'echo "TODO: define build command"',
294
+ lint: typeof commands.lint === "string" ? commands.lint : defaults.commands.lint,
295
+ test: typeof commands.test === "string" ? commands.test : defaults.commands.test,
296
+ build: typeof commands.build === "string" ? commands.build : defaults.commands.build,
240
297
  },
241
- done,
298
+ done: done.length > 0 ? done : defaults.done,
242
299
  approvals: {
243
- mode:
244
- typeof approvals.mode === "string" ? approvals.mode : "ask-before-destructive",
300
+ mode: typeof approvals.mode === "string" ? approvals.mode : defaults.approvals.mode,
245
301
  notes,
246
302
  },
303
+ targets,
247
304
  };
248
305
  }
249
306
 
250
- function readRulesOrThrow() {
251
- if (!existsSync(RULES_FILE)) {
252
- throw new Error(`Missing ${RULES_FILE}. Run "rules-doctor init" to create it first.`);
307
+ function stringifyRules(rules) {
308
+ const knownTargetIds = ADAPTERS.map((adapter) => adapter.id);
309
+ const allTargetIds = [
310
+ ...knownTargetIds.filter((id) => Object.prototype.hasOwnProperty.call(rules.targets || {}, id)),
311
+ ...Object.keys(rules.targets || {})
312
+ .filter((id) => !knownTargetIds.includes(id))
313
+ .sort(),
314
+ ];
315
+
316
+ const lines = [
317
+ `version: ${quoteYaml(Number.isFinite(rules.version) ? rules.version : 2)}`,
318
+ `mission: ${quoteYaml(rules.mission)}`,
319
+ "workflow:",
320
+ ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
321
+ "commands:",
322
+ ...Object.keys(rules.commands).map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
323
+ "done:",
324
+ ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
325
+ "approvals:",
326
+ ` mode: ${quoteYaml(rules.approvals.mode)}`,
327
+ " notes:",
328
+ ...rules.approvals.notes.map((note) => ` - ${quoteYaml(note)}`),
329
+ "targets:",
330
+ ];
331
+
332
+ for (const id of allTargetIds) {
333
+ const config = normalizeTargetConfig(rules.targets[id], `${id.toUpperCase()}.md`);
334
+ lines.push(` ${id}:`);
335
+ lines.push(` enabled: ${quoteYaml(config.enabled)}`);
336
+ lines.push(` path: ${quoteYaml(config.path)}`);
253
337
  }
254
338
 
255
- const raw = readFileSync(RULES_FILE, "utf8");
256
- return normalizeRules(parseRulesText(raw));
339
+ lines.push("");
340
+ return lines.join("\n");
257
341
  }
258
342
 
259
- function formatList(items) {
260
- if (!Array.isArray(items) || items.length === 0) {
261
- return "- (none)";
262
- }
263
- return items.map((item) => `- ${item}`).join("\n");
343
+ function hasVerifyCommand(text) {
344
+ return /\b(npm run|pnpm|yarn|bun)\s+(lint|test|build)\b/i.test(text);
264
345
  }
265
346
 
266
- function formatCommands(commands) {
267
- if (!commands || typeof commands !== "object") {
268
- return "- (none)";
269
- }
347
+ function hasNoApprovalLanguage(text) {
348
+ return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
349
+ text,
350
+ );
351
+ }
270
352
 
271
- const preferredOrder = ["lint", "test", "build"];
272
- const names = [
273
- ...preferredOrder.filter((name) => Object.prototype.hasOwnProperty.call(commands, name)),
274
- ...Object.keys(commands).filter((name) => !preferredOrder.includes(name)),
275
- ];
353
+ function hasAskApprovalLanguage(text) {
354
+ return /ask for approval|request approval|require approval|needs approval/i.test(text);
355
+ }
276
356
 
277
- if (names.length === 0) {
278
- return "- (none)";
279
- }
357
+ function hasRequireTestsLanguage(text) {
358
+ return /must run tests|always run tests|run tests before done/i.test(text);
359
+ }
280
360
 
281
- return names.map((name) => `- ${name}: \`${commands[name]}\``).join("\n");
361
+ function hasSkipTestsLanguage(text) {
362
+ return /skip tests|tests optional|do not run tests/i.test(text);
282
363
  }
283
364
 
284
- function renderClaude(rules) {
285
- return [
286
- "# CLAUDE.md",
287
- "",
288
- "## Mission",
289
- rules.mission,
290
- "",
291
- "## Workflow",
292
- formatList(rules.workflow),
293
- "",
294
- "## Commands",
295
- formatCommands(rules.commands),
296
- "",
297
- "## Done",
298
- formatList(rules.done),
299
- "",
300
- "## Approvals",
301
- `- Mode: \`${rules.approvals.mode}\``,
302
- ...rules.approvals.notes.map((note) => `- ${note}`),
303
- "",
304
- ].join("\n");
365
+ function resolveInRoot(rootDir, filePath) {
366
+ if (isAbsolute(filePath)) {
367
+ return filePath;
368
+ }
369
+ return resolve(rootDir, filePath);
305
370
  }
306
371
 
307
- function renderCodexManagedSection(rules) {
308
- return [
309
- "## rules-doctor Managed Rules",
310
- "Generated from `.agentrules/rules.yaml`. Edit that file, then run `rules-doctor sync`.",
311
- "",
312
- "### Mission",
313
- rules.mission,
314
- "",
315
- "### Operational Loop",
316
- "1. Read relevant context and constraints before editing.",
317
- "2. Select and run the smallest command that moves the task forward.",
318
- "3. Apply focused changes.",
319
- "4. Run verification commands and report exact outcomes.",
320
- "",
321
- "### Commands",
322
- formatCommands(rules.commands),
323
- "",
324
- "### Failure Loop",
325
- "1. Capture the exact failing command and error output.",
326
- "2. Form one concrete hypothesis for the failure.",
327
- "3. Apply one fix and rerun the same command.",
328
- "4. Repeat until green or blocked, then report blocker and next action.",
329
- "",
330
- "### Done",
331
- formatList(rules.done),
332
- "",
333
- "### Approvals",
334
- `- Policy: \`${rules.approvals.mode}\``,
335
- ...rules.approvals.notes.map((note) => `- ${note}`),
336
- "",
337
- ].join("\n");
372
+ function findProjectRoot(startDir) {
373
+ let current = resolve(startDir);
374
+ while (true) {
375
+ const gitPath = resolve(current, ".git");
376
+ const rulesPath = resolve(current, RULES_RELATIVE_PATH);
377
+ if (existsSync(rulesPath) || existsSync(gitPath)) {
378
+ return current;
379
+ }
380
+
381
+ const parent = dirname(current);
382
+ if (parent === current) {
383
+ return resolve(startDir);
384
+ }
385
+ current = parent;
386
+ }
338
387
  }
339
388
 
340
- function upsertManagedSection(existing, content) {
341
- const start = existing.indexOf(MARKER_BEGIN);
342
- const end = start >= 0 ? existing.indexOf(MARKER_END, start) : -1;
389
+ function ensureParentDirectory(filePath) {
390
+ mkdirSync(dirname(filePath), { recursive: true });
391
+ }
392
+
393
+ function upsertManagedSection(existing, content, beginMarker, endMarker) {
394
+ const start = existing.indexOf(beginMarker);
395
+ const end = start >= 0 ? existing.indexOf(endMarker, start) : -1;
343
396
 
344
397
  if (start >= 0 && end > start) {
345
- const before = existing.slice(0, start + MARKER_BEGIN.length);
398
+ const before = existing.slice(0, start + beginMarker.length);
346
399
  const after = existing.slice(end);
347
400
  return `${before}\n${content.trim()}\n${after}`.replace(/\n{3,}/g, "\n\n");
348
401
  }
349
402
 
350
403
  const base = existing.trimEnd();
351
404
  const prefix = base ? `${base}\n\n` : "";
352
- return `${prefix}${MARKER_BEGIN}\n${content.trim()}\n${MARKER_END}\n`;
405
+ return `${prefix}${beginMarker}\n${content.trim()}\n${endMarker}\n`;
353
406
  }
354
407
 
355
- function initCommand() {
356
- if (existsSync(RULES_FILE)) {
357
- console.log(`rules.yaml already exists: ${RULES_FILE}`);
358
- return;
408
+ function loadPackageScripts(rootDir) {
409
+ const pkg = readJsonFile(resolve(rootDir, "package.json"));
410
+ if (!pkg || typeof pkg !== "object" || !pkg.scripts || typeof pkg.scripts !== "object") {
411
+ return {};
412
+ }
413
+ return pkg.scripts;
414
+ }
415
+
416
+ function loadRules(rootDir, options) {
417
+ const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
418
+ const defaults = createDefaultRules(loadPackageScripts(rootDir));
419
+
420
+ if (!existsSync(rulesFile)) {
421
+ if (options && options.allowMissing) {
422
+ return {
423
+ rules: defaults,
424
+ rulesFile,
425
+ rulesExists: false,
426
+ };
427
+ }
428
+ throw new Error(`Missing ${rulesFile}. Run "rules-doctor init" to create it first.`);
359
429
  }
360
430
 
361
- mkdirSync(resolve(".agentrules"), { recursive: true });
362
- writeFileSync(RULES_FILE, stringifyRules(createDefaultRules()), "utf8");
363
- console.log(`Created ${RULES_FILE}`);
431
+ const parsed = parseRulesText(readFileSync(rulesFile, "utf8"));
432
+ return {
433
+ rules: normalizeRules(parsed, defaults),
434
+ rulesFile,
435
+ rulesExists: true,
436
+ };
364
437
  }
365
438
 
366
- function syncCommand(target) {
367
- const rules = readRulesOrThrow();
439
+ function getTargetsFromSpec(spec) {
440
+ if (spec === "all") {
441
+ return ADAPTERS.map((adapter) => adapter.id);
442
+ }
368
443
 
369
- if (target === "all" || target === "claude") {
370
- writeFileSync(CLAUDE_FILE, renderClaude(rules), "utf8");
371
- console.log(`Updated ${CLAUDE_FILE}`);
444
+ const unique = [];
445
+ for (const raw of spec.split(",")) {
446
+ const id = raw.trim();
447
+ if (!id) {
448
+ continue;
449
+ }
450
+ if (!ADAPTERS_BY_ID[id]) {
451
+ throw new Error(
452
+ `Unknown target "${id}". Use one of: all, ${ADAPTERS.map((adapter) => adapter.id).join(", ")}`,
453
+ );
454
+ }
455
+ if (!unique.includes(id)) {
456
+ unique.push(id);
457
+ }
372
458
  }
373
459
 
374
- if (target === "all" || target === "codex") {
375
- const existing = existsSync(AGENTS_FILE) ? readFileSync(AGENTS_FILE, "utf8") : "";
376
- const updated = upsertManagedSection(existing, renderCodexManagedSection(rules));
377
- writeFileSync(AGENTS_FILE, updated, "utf8");
378
- console.log(`Updated ${AGENTS_FILE}`);
460
+ if (unique.length === 0) {
461
+ throw new Error("No targets selected.");
379
462
  }
380
- }
381
463
 
382
- function hasVerifyCommand(text) {
383
- return /\b(npm run|pnpm|yarn)\s+(lint|test|build)\b/i.test(text);
464
+ return unique;
384
465
  }
385
466
 
386
- function hasNoApprovalLanguage(text) {
387
- return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
388
- text,
389
- );
390
- }
467
+ function parseSyncTargets(args) {
468
+ if (!args || args.length === 0) {
469
+ return ADAPTERS.map((adapter) => adapter.id);
470
+ }
391
471
 
392
- function hasAskApprovalLanguage(text) {
393
- return /ask for approval|request approval|require approval|needs approval/i.test(text);
394
- }
472
+ let targetSpec = "all";
473
+ for (let index = 0; index < args.length; index += 1) {
474
+ const arg = args[index];
475
+ if (arg === "--target") {
476
+ const value = args[index + 1];
477
+ if (!value) {
478
+ throw new Error("Missing value for --target");
479
+ }
480
+ targetSpec = value;
481
+ index += 1;
482
+ continue;
483
+ }
395
484
 
396
- function hasRequireTestsLanguage(text) {
397
- return /must run tests|always run tests|run tests before done/i.test(text);
398
- }
485
+ throw new Error(`Unknown option for sync: ${arg}`);
486
+ }
399
487
 
400
- function hasSkipTestsLanguage(text) {
401
- return /skip tests|tests optional|do not run tests/i.test(text);
488
+ return getTargetsFromSpec(targetSpec);
402
489
  }
403
490
 
404
- function analyzeCommand() {
405
- const claudeExists = existsSync(CLAUDE_FILE);
406
- const agentsExists = existsSync(AGENTS_FILE);
407
- const claude = claudeExists ? readFileSync(CLAUDE_FILE, "utf8") : "";
408
- const agents = agentsExists ? readFileSync(AGENTS_FILE, "utf8") : "";
409
- const issues = [];
410
-
411
- if (!claudeExists) {
412
- issues.push("CLAUDE.md missing.");
413
- }
414
- if (!agentsExists) {
415
- issues.push("AGENTS.md missing.");
491
+ function parseAnalyzeArgs(args) {
492
+ if (!args || args.length === 0) {
493
+ return {};
416
494
  }
417
495
 
418
- if (agentsExists) {
419
- const hasBegin = agents.includes(MARKER_BEGIN);
420
- const hasEnd = agents.includes(MARKER_END);
421
- if (!hasBegin || !hasEnd) {
422
- issues.push("AGENTS.md missing rules-doctor markers.");
496
+ for (const arg of args) {
497
+ if (arg === "--strict") {
498
+ return { strict: true };
423
499
  }
500
+ throw new Error(`Unknown option for analyze: ${arg}`);
424
501
  }
425
502
 
426
- if (claudeExists && !hasVerifyCommand(claude)) {
427
- issues.push("CLAUDE.md appears to be missing verify commands (lint/test/build).");
428
- }
503
+ return {};
504
+ }
429
505
 
430
- if (agentsExists && !hasVerifyCommand(agents)) {
431
- issues.push("AGENTS.md appears to be missing verify commands (lint/test/build).");
432
- }
506
+ function getTargetConfig(rules, adapter) {
507
+ const source = rules.targets && typeof rules.targets === "object" ? rules.targets[adapter.id] : null;
508
+ return normalizeTargetConfig(source, adapter.defaultPath);
509
+ }
433
510
 
434
- if (
435
- (hasNoApprovalLanguage(claude) && hasAskApprovalLanguage(agents)) ||
436
- (hasAskApprovalLanguage(claude) && hasNoApprovalLanguage(agents))
437
- ) {
438
- issues.push("Potential contradiction: approval guidance differs between CLAUDE.md and AGENTS.md.");
511
+ function initCommand(rootDir, logger) {
512
+ const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
513
+ if (existsSync(rulesFile)) {
514
+ logger.log(`rules.yaml already exists: ${rulesFile}`);
515
+ return;
439
516
  }
440
517
 
441
- if (
442
- (hasRequireTestsLanguage(claude) && hasSkipTestsLanguage(agents)) ||
443
- (hasSkipTestsLanguage(claude) && hasRequireTestsLanguage(agents))
444
- ) {
445
- issues.push("Potential contradiction: test guidance differs between CLAUDE.md and AGENTS.md.");
446
- }
518
+ const defaults = createDefaultRules(loadPackageScripts(rootDir));
519
+ ensureParentDirectory(rulesFile);
520
+ writeFileSync(rulesFile, stringifyRules(defaults), "utf8");
521
+ logger.log(`Created ${rulesFile}`);
522
+ }
447
523
 
448
- console.log("rules-doctor analyze");
449
- console.log(`- CLAUDE.md: ${claudeExists ? "found" : "missing"}`);
450
- console.log(`- AGENTS.md: ${agentsExists ? "found" : "missing"}`);
451
- console.log("- Findings:");
524
+ function syncCommand(rootDir, logger, args) {
525
+ const { rules } = loadRules(rootDir);
526
+ const selectedTargetIds = parseSyncTargets(args);
452
527
 
453
- if (issues.length === 0) {
454
- console.log("- No obvious issues found.");
455
- return;
528
+ let updated = 0;
529
+ for (const targetId of selectedTargetIds) {
530
+ const adapter = ADAPTERS_BY_ID[targetId];
531
+ const target = getTargetConfig(rules, adapter);
532
+
533
+ if (!target.enabled) {
534
+ logger.log(`Skipped ${targetId} (disabled in rules.yaml).`);
535
+ continue;
536
+ }
537
+
538
+ const targetPath = resolveInRoot(rootDir, target.path);
539
+ const rendered = adapter.render(rules).trim();
540
+ ensureParentDirectory(targetPath);
541
+
542
+ if (adapter.management === "marker") {
543
+ const existing = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
544
+ const updatedText = upsertManagedSection(
545
+ existing,
546
+ rendered,
547
+ adapter.markerBegin,
548
+ adapter.markerEnd,
549
+ );
550
+ writeFileSync(targetPath, updatedText, "utf8");
551
+ } else {
552
+ writeFileSync(targetPath, `${rendered}\n`, "utf8");
553
+ }
554
+
555
+ logger.log(`Updated ${targetPath} (${targetId})`);
556
+ updated += 1;
456
557
  }
457
558
 
458
- for (const issue of issues) {
459
- console.log(`- ${issue}`);
559
+ if (updated === 0) {
560
+ logger.log("No files updated.");
460
561
  }
461
562
  }
462
563
 
463
- function parseSyncTarget(args) {
464
- let target = "all";
564
+ function analyzeCommand(rootDir, logger, args) {
565
+ const options = parseAnalyzeArgs(args);
566
+ const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
567
+ const issues = [];
568
+ const targetSnapshots = [];
465
569
 
466
- for (let index = 0; index < args.length; index += 1) {
467
- const arg = args[index];
468
- if (arg === "--target") {
469
- const value = args[index + 1];
470
- if (value !== "all" && value !== "claude" && value !== "codex") {
471
- throw new Error('Invalid --target value. Use one of: "all", "claude", "codex".');
472
- }
473
- target = value;
474
- index += 1;
570
+ logger.log("rules-doctor analyze");
571
+ logger.log(`- rules.yaml: ${rulesExists ? "found" : "missing (using defaults)"}`);
572
+ logger.log(`- rules path: ${rulesFile}`);
573
+
574
+ for (const adapter of ADAPTERS) {
575
+ const target = getTargetConfig(rules, adapter);
576
+ const absolutePath = resolveInRoot(rootDir, target.path);
577
+ const fileExists = existsSync(absolutePath);
578
+ const content = fileExists ? readFileSync(absolutePath, "utf8") : "";
579
+
580
+ logger.log(
581
+ `- target ${adapter.id}: ${target.enabled ? "enabled" : "disabled"}, ${
582
+ fileExists ? "found" : "missing"
583
+ } (${target.path})`,
584
+ );
585
+
586
+ if (!target.enabled) {
475
587
  continue;
476
588
  }
477
589
 
478
- throw new Error(`Unknown option for sync: ${arg}`);
590
+ if (!fileExists) {
591
+ issues.push(`${adapter.id}: expected file is missing (${target.path}).`);
592
+ continue;
593
+ }
594
+
595
+ if (adapter.management === "marker") {
596
+ const hasBegin = content.includes(adapter.markerBegin);
597
+ const hasEnd = content.includes(adapter.markerEnd);
598
+ if (!hasBegin || !hasEnd) {
599
+ issues.push(`${adapter.id}: marker block is missing.`);
600
+ }
601
+ }
602
+
603
+ if (!hasVerifyCommand(content)) {
604
+ issues.push(`${adapter.id}: verify commands (lint/test/build) not detected.`);
605
+ }
606
+
607
+ targetSnapshots.push({
608
+ id: adapter.id,
609
+ content,
610
+ asksApproval: hasAskApprovalLanguage(content),
611
+ noApproval: hasNoApprovalLanguage(content),
612
+ requiresTests: hasRequireTestsLanguage(content),
613
+ skipsTests: hasSkipTestsLanguage(content),
614
+ });
479
615
  }
480
616
 
481
- return target;
482
- }
617
+ const askApprovalTargets = targetSnapshots.filter((item) => item.asksApproval).map((item) => item.id);
618
+ const noApprovalTargets = targetSnapshots.filter((item) => item.noApproval).map((item) => item.id);
619
+ if (askApprovalTargets.length > 0 && noApprovalTargets.length > 0) {
620
+ issues.push(
621
+ `Potential contradiction: approval guidance differs (${askApprovalTargets.join(
622
+ ", ",
623
+ )} vs ${noApprovalTargets.join(", ")}).`,
624
+ );
625
+ }
483
626
 
484
- function main() {
485
- const [command, ...args] = process.argv.slice(2);
627
+ const requireTestsTargets = targetSnapshots
628
+ .filter((item) => item.requiresTests)
629
+ .map((item) => item.id);
630
+ const skipTestsTargets = targetSnapshots.filter((item) => item.skipsTests).map((item) => item.id);
631
+ if (requireTestsTargets.length > 0 && skipTestsTargets.length > 0) {
632
+ issues.push(
633
+ `Potential contradiction: test guidance differs (${requireTestsTargets.join(
634
+ ", ",
635
+ )} vs ${skipTestsTargets.join(", ")}).`,
636
+ );
637
+ }
486
638
 
487
- if (!command || command === "--help" || command === "-h") {
488
- console.log(usage());
489
- return;
639
+ logger.log("- Findings:");
640
+ if (issues.length === 0) {
641
+ logger.log("- No obvious issues found.");
642
+ return 0;
490
643
  }
491
644
 
492
- if (command === "init") {
493
- initCommand();
494
- return;
645
+ for (const issue of issues) {
646
+ logger.log(`- ${issue}`);
495
647
  }
496
648
 
497
- if (command === "sync") {
498
- syncCommand(parseSyncTarget(args));
499
- return;
649
+ if (options.strict) {
650
+ throw new Error(`Analyze failed in strict mode with ${issues.length} issue(s).`);
500
651
  }
501
652
 
502
- if (command === "analyze") {
503
- analyzeCommand();
504
- return;
653
+ return 0;
654
+ }
655
+
656
+ function targetsListCommand(logger) {
657
+ logger.log("Supported targets:");
658
+ for (const adapter of ADAPTERS) {
659
+ const mode = adapter.management === "marker" ? "marker-managed" : "full-managed";
660
+ logger.log(`- ${adapter.id}: ${adapter.name}`);
661
+ logger.log(` default path: ${adapter.defaultPath}`);
662
+ logger.log(` mode: ${mode}`);
663
+ logger.log(` ${adapter.description}`);
505
664
  }
665
+ }
666
+
667
+ function runCli(argv, options) {
668
+ const args = Array.isArray(argv) ? argv : [];
669
+ const logger = createLogger(options || {});
670
+ const cwd = resolve(options && options.cwd ? options.cwd : process.cwd());
671
+ const rootDir = findProjectRoot(cwd);
506
672
 
507
- throw new Error(`Unknown command: ${command}\n\n${usage()}`);
673
+ try {
674
+ const [command, ...rest] = args;
675
+
676
+ if (!command || command === "--help" || command === "-h") {
677
+ logger.log(usage());
678
+ return 0;
679
+ }
680
+
681
+ if (command === "init") {
682
+ initCommand(rootDir, logger);
683
+ return 0;
684
+ }
685
+
686
+ if (command === "sync") {
687
+ syncCommand(rootDir, logger, rest);
688
+ return 0;
689
+ }
690
+
691
+ if (command === "analyze") {
692
+ return analyzeCommand(rootDir, logger, rest);
693
+ }
694
+
695
+ if (command === "targets") {
696
+ if (rest.length === 1 && rest[0] === "list") {
697
+ targetsListCommand(logger);
698
+ return 0;
699
+ }
700
+ throw new Error('Unknown targets command. Use "rules-doctor targets list".');
701
+ }
702
+
703
+ throw new Error(`Unknown command: ${command}\n\n${usage()}`);
704
+ } catch (error) {
705
+ const message = error instanceof Error ? error.message : String(error);
706
+ logger.error(`Error: ${message}`);
707
+ return 1;
708
+ }
508
709
  }
509
710
 
510
- try {
511
- main();
512
- } catch (error) {
513
- const message = error instanceof Error ? error.message : String(error);
514
- console.error(`Error: ${message}`);
515
- process.exitCode = 1;
711
+ if (require.main === module) {
712
+ process.exitCode = runCli(process.argv.slice(2));
516
713
  }
517
714
 
715
+ module.exports = {
716
+ ADAPTERS,
717
+ createDefaultRules,
718
+ normalizeRules,
719
+ parseRulesText,
720
+ runCli,
721
+ stringifyRules,
722
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jazpiper/rules-doctor",
3
- "version": "0.1.0",
4
- "description": "Node.js CLI to keep agent rules in sync across CLAUDE.md and AGENTS.md",
3
+ "version": "0.2.0",
4
+ "description": "Node.js CLI to sync shared coding rules across multiple agent/CLI targets",
5
5
  "license": "MIT",
6
6
  "bin": {
7
7
  "rules-doctor": "dist/index.js"
@@ -14,7 +14,7 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "scripts": {
17
- "build": "node scripts/tsup.js src/index.js --clean",
17
+ "build": "node scripts/tsup.js src/index.js --clean --dts",
18
18
  "test": "npm run build && node --test"
19
19
  },
20
20
  "keywords": [
@@ -22,7 +22,11 @@
22
22
  "agents",
23
23
  "rules",
24
24
  "claude",
25
- "codex"
25
+ "codex",
26
+ "cursor",
27
+ "gemini",
28
+ "opencode",
29
+ "antigravity"
26
30
  ],
27
31
  "engines": {
28
32
  "node": ">=18"