@dalzoubi/dev-agents-sync 1.0.26 → 2.0.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
@@ -41,18 +41,32 @@ npx --yes @dalzoubi/dev-agents-sync@1 init --targets claude
41
41
  This writes managed files into `.claude/` and creates `.dev-agents-sync.json` with the resolved content version.
42
42
  Use `--targets cursor` or `--targets claude,cursor` to install Cursor project agents, rules, and slash commands into `.cursor/agents/`, `.cursor/rules/`, and `.cursor/commands/`.
43
43
 
44
+ As part of `init`, the CLI injects an auto-routing fenced block into `CLAUDE.md` (creating the file if it does not exist). The block tells Claude Code how to route tasks to the correct agent. It is delimited by:
45
+
46
+ ```html
47
+ <!-- dev-agents:auto-route:start -->
48
+ ...auto-generated routing instructions...
49
+ <!-- dev-agents:auto-route:end -->
50
+ ```
51
+
52
+ Do not hand-edit content inside those markers — it will be overwritten on the next `update`.
53
+
44
54
  Update to the latest matching content version:
45
55
 
46
56
  ```bash
47
57
  npx --yes @dalzoubi/dev-agents-sync@1 update
48
58
  ```
49
59
 
60
+ `update` refreshes managed agent files **and** replaces the `CLAUDE.md` auto-routing block if it is stale, so routing instructions always match the installed content version.
61
+
50
62
  Check whether managed files are in sync:
51
63
 
52
64
  ```bash
53
65
  npx --yes @dalzoubi/dev-agents-sync@1 check
54
66
  ```
55
67
 
68
+ `check` verifies that managed agent files and the `CLAUDE.md` auto-routing block are both up to date. If the routing block is absent or stale, `check` exits with code 1 and names `CLAUDE.md` in the output. This makes it safe to use in CI to detect drift.
69
+
56
70
  Show the expected diff without writing files:
57
71
 
58
72
  ```bash
@@ -0,0 +1,9 @@
1
+ export default [
2
+ {
3
+ files: ["**/*.mjs"],
4
+ languageOptions: {
5
+ ecmaVersion: 2022,
6
+ sourceType: "module",
7
+ },
8
+ },
9
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "1.0.26",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,220 @@
1
+ /**
2
+ * claudeMd.mjs
3
+ *
4
+ * Helper for injecting and updating the auto-route fenced block in a
5
+ * consumer's CLAUDE.md file.
6
+ *
7
+ * @internal — not part of the public package API
8
+ *
9
+ * Fenced block format:
10
+ * <!-- dev-agents:auto-route:start managed-by: dev-agents-sync vX.Y.Z -->
11
+ * ...routing rules content...
12
+ * <!-- dev-agents:auto-route:end -->
13
+ *
14
+ * Design notes:
15
+ * - The version in the start marker is the CLI package version, not the
16
+ * resolved content version. This lets consumers detect that their routing
17
+ * rules were written by a specific CLI release.
18
+ * - We never clobber a malformed block (start present, end missing). Doing so
19
+ * would silently destroy user content in the region below the orphaned
20
+ * start marker. Warn and skip instead.
21
+ * - Deterministic output: same inputs → same file content (§9 CONSTITUTION).
22
+ */
23
+
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Exported constants — load-bearing: tests and callers grep for these exact
29
+ // literal strings, so do not change them without a migration story.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const START_MARKER_PREFIX = '<!-- dev-agents:auto-route:start managed-by: dev-agents-sync v';
33
+ export const END_MARKER = '<!-- dev-agents:auto-route:end -->';
34
+
35
+ // Regex that matches the full start marker and captures the version.
36
+ // Must stay in sync with START_MARKER_PREFIX above.
37
+ const START_MARKER_RE = /<!--\s*dev-agents:auto-route:start\s+managed-by:\s*dev-agents-sync\s+v([^\s]+)\s*-->/;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // buildFencedBlock
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Builds the full fenced block string.
45
+ *
46
+ * @param {string} version CLI package version (e.g. "1.0.25")
47
+ * @param {string} content Routing rules content to embed between the markers
48
+ * @returns {string}
49
+ */
50
+ export function buildFencedBlock(version, content) {
51
+ return [
52
+ `${START_MARKER_PREFIX}${version} -->`,
53
+ content,
54
+ END_MARKER,
55
+ ].join('\n');
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // parseExistingBlock
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Scans `fileContent` for an existing auto-route fenced block.
64
+ *
65
+ * Returns:
66
+ * null — no block present (safe to append)
67
+ * { version, block } — well-formed block found; `block` is the
68
+ * full text from start marker to end marker
69
+ * (inclusive, with the newline between them)
70
+ * { malformed: true } — start marker found but end marker is absent
71
+ *
72
+ * @param {string} fileContent
73
+ * @returns {null | { version: string, block: string } | { malformed: true }}
74
+ */
75
+ export function parseExistingBlock(fileContent) {
76
+ if (!fileContent) return null;
77
+
78
+ const startMatch = fileContent.match(START_MARKER_RE);
79
+ if (!startMatch) return null;
80
+
81
+ const startIndex = fileContent.indexOf(startMatch[0]);
82
+ const version = startMatch[1];
83
+
84
+ const endIndex = fileContent.indexOf(END_MARKER, startIndex + startMatch[0].length);
85
+ if (endIndex === -1) {
86
+ return { malformed: true };
87
+ }
88
+
89
+ // Include the full block from start marker through end marker (no trailing newline).
90
+ const block = fileContent.slice(startIndex, endIndex + END_MARKER.length);
91
+ return { version, block };
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // injectClaudeMd
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Injects or replaces the auto-route fenced block in a CLAUDE.md file.
100
+ *
101
+ * Behaviour:
102
+ * - File does not exist → create it with just the fenced block
103
+ * - File exists, no block → append the fenced block (preserving existing prose)
104
+ * - File exists, well-formed block, same version+content → skip silently
105
+ * - File exists, well-formed block, stale → replace in-place; prose untouched
106
+ * - File exists, malformed block (start, no end) → warn stderr, skip
107
+ * - dryRun: true → return plan, write nothing
108
+ *
109
+ * @param {string} absPath Absolute path to CLAUDE.md
110
+ * @param {string} version CLI package version string
111
+ * @param {string} content Routing rules content to embed
112
+ * @param {{ dryRun?: boolean }} [opts]
113
+ * @returns {{ skipped?: boolean, malformed?: boolean, dryRun?: boolean,
114
+ * action?: string, plannedContent?: string }}
115
+ */
116
+ export function injectClaudeMd(absPath, version, content, opts = {}) {
117
+ const { dryRun = false } = opts;
118
+
119
+ const newBlock = buildFencedBlock(version, content);
120
+
121
+ // -------------------------------------------------------------------------
122
+ // Determine current state
123
+ // -------------------------------------------------------------------------
124
+
125
+ let existingContent = null;
126
+ if (existsSync(absPath)) {
127
+ existingContent = readFileSync(absPath, 'utf8');
128
+ }
129
+
130
+ // Empty string is treated the same as absent — nothing to preserve.
131
+ const hasExistingContent = existingContent !== null && existingContent.length > 0;
132
+
133
+ let parsed = null;
134
+ if (hasExistingContent) {
135
+ parsed = parseExistingBlock(existingContent);
136
+ }
137
+
138
+ // -------------------------------------------------------------------------
139
+ // Malformed block — warn and skip
140
+ // -------------------------------------------------------------------------
141
+
142
+ if (parsed && parsed.malformed) {
143
+ process.stderr.write(
144
+ `warn: dev-agents-sync: CLAUDE.md has a malformed auto-route block ` +
145
+ `(start marker present but end marker is missing). ` +
146
+ `Skipping injection to avoid corrupting the file. ` +
147
+ `Remove or repair the block manually and re-run.\n`,
148
+ );
149
+ return { skipped: true, malformed: true };
150
+ }
151
+
152
+ // -------------------------------------------------------------------------
153
+ // Skip when current — version AND content are identical
154
+ // -------------------------------------------------------------------------
155
+
156
+ if (parsed && parsed.version === version && parsed.block === newBlock) {
157
+ return { skipped: true };
158
+ }
159
+
160
+ // -------------------------------------------------------------------------
161
+ // Dry run — compute planned content but do not write
162
+ // -------------------------------------------------------------------------
163
+
164
+ if (dryRun) {
165
+ const plannedContent = computeNewContent(existingContent, parsed, newBlock);
166
+ return { dryRun: true, action: parsed ? 'replace' : 'inject', plannedContent };
167
+ }
168
+
169
+ // -------------------------------------------------------------------------
170
+ // Write
171
+ // -------------------------------------------------------------------------
172
+
173
+ const newContent = computeNewContent(existingContent, parsed, newBlock);
174
+
175
+ mkdirSync(path.dirname(absPath), { recursive: true });
176
+ try {
177
+ writeFileSync(absPath, newContent, 'utf8');
178
+ } catch (err) {
179
+ const wrapped = new Error('Cannot write to CLAUDE.md — check file permissions.');
180
+ wrapped.cause = err;
181
+ wrapped.exitCode = 2;
182
+ throw wrapped;
183
+ }
184
+
185
+ return { action: parsed ? 'replace' : 'inject' };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Internal helper
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Computes the new full file content by either:
194
+ * - Replacing the existing block in-place (preserving surrounding prose), or
195
+ * - Appending the block to whatever content exists (possibly empty/absent)
196
+ *
197
+ * @param {string | null} existingContent Current file content, or null if absent
198
+ * @param {{ block: string } | null} parsed parseExistingBlock result (non-malformed)
199
+ * @param {string} newBlock The fully-formed fenced block to inject
200
+ * @returns {string}
201
+ */
202
+ function computeNewContent(existingContent, parsed, newBlock) {
203
+ // Case 1: file absent or empty — write just the fenced block
204
+ if (!existingContent || existingContent.length === 0) {
205
+ return newBlock + '\n';
206
+ }
207
+
208
+ // Case 2: existing block to replace in-place
209
+ if (parsed && parsed.block) {
210
+ // Use the function form of replace to avoid $-sequence corruption
211
+ // (e.g. `$$`, `$&`, `$1` in newBlock would otherwise be misinterpreted
212
+ // as replacement patterns by the String.prototype.replace algorithm).
213
+ return existingContent.replace(parsed.block, () => newBlock);
214
+ }
215
+
216
+ // Case 3: file has prose but no block — append
217
+ // Ensure a blank line separates the existing prose from the fenced block.
218
+ const trimmed = existingContent.trimEnd();
219
+ return trimmed + '\n\n' + newBlock + '\n';
220
+ }
@@ -7,9 +7,15 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
10
12
 
11
13
  import { readLockfile } from '../lockfile.mjs';
12
14
  import { resolveConsumerPath, normalizeFileMap } from '../writer.mjs';
15
+ import { parseExistingBlock, buildFencedBlock } from '../claudeMd.mjs';
16
+
17
+ const _require = createRequire(import.meta.url);
18
+ const CLI_VERSION = _require('../../package.json').version;
13
19
 
14
20
  function filterFileMapByTargets(fileMap, targets) {
15
21
  const out = {};
@@ -46,7 +52,8 @@ export async function runCheck(consumerCwd, opts = {}) {
46
52
  throw wrapped;
47
53
  }
48
54
 
49
- const scoped = filterFileMapByTargets(normalizeFileMap(fileMap), lock.targets);
55
+ const normalized = normalizeFileMap(fileMap);
56
+ const scoped = filterFileMapByTargets(normalized, lock.targets);
50
57
 
51
58
  const drifted = [];
52
59
 
@@ -65,6 +72,64 @@ export async function runCheck(consumerCwd, opts = {}) {
65
72
  }
66
73
  }
67
74
 
75
+ // -------------------------------------------------------------------------
76
+ // CLAUDE.md fenced block drift check
77
+ //
78
+ // Only applies when the `claude` target is active. We look for the routing
79
+ // content at `claude/skills/auto-route.md` in the fetched file map and
80
+ // compare the consumer's CLAUDE.md fenced block against the expected block
81
+ // built from the current CLI version + that routing content.
82
+ //
83
+ // All three problem states are treated as drift (not tooling error):
84
+ // - CLAUDE.md absent
85
+ // - fenced block absent (CLAUDE.md has prose but no block)
86
+ // - fenced block malformed (start marker, no end marker)
87
+ // - fenced block stale (version or content differs from expected)
88
+ // -------------------------------------------------------------------------
89
+
90
+ const claudeTargetActive = lock.targets.includes('claude');
91
+ if (claudeTargetActive) {
92
+ const routingContent = normalized['claude/skills/auto-route.md'] ?? null;
93
+
94
+ if (routingContent === null) {
95
+ // Skip is intentional when the remote skill is absent — the consumer may
96
+ // be pinned to a version that predates the auto-route skill file. This
97
+ // is not drift (no expected block to compare against), but it should not
98
+ // be silent either, so we emit a structured warning.
99
+ process.stderr.write(
100
+ 'warn: CLAUDE.md drift check skipped — claude/skills/auto-route.md not present in fetched content\n',
101
+ );
102
+ } else {
103
+ const claudeMdPath = path.join(consumerCwd, 'CLAUDE.md');
104
+ const expectedBlock = buildFencedBlock(CLI_VERSION, routingContent);
105
+
106
+ let isDrifted = false;
107
+ if (!existsSync(claudeMdPath)) {
108
+ // Absence is drift — CLAUDE.md should always carry the routing block
109
+ // after init/update has run.
110
+ isDrifted = true;
111
+ } else {
112
+ const claudeMdContent = readFileSync(claudeMdPath, 'utf8');
113
+ const parsed = parseExistingBlock(claudeMdContent);
114
+
115
+ if (parsed === null) {
116
+ // No block present at all.
117
+ isDrifted = true;
118
+ } else if (parsed.malformed) {
119
+ // Start marker with no end marker — treat as drift, not a crash.
120
+ isDrifted = true;
121
+ } else if (parsed.block !== expectedBlock) {
122
+ // Block present but version or content differs.
123
+ isDrifted = true;
124
+ }
125
+ }
126
+
127
+ if (isDrifted) {
128
+ drifted.push('CLAUDE.md');
129
+ }
130
+ }
131
+ }
132
+
68
133
  if (drifted.length === 0) {
69
134
  return { inSync: true, resolvedVersion: lock.resolvedVersion };
70
135
  }
@@ -8,11 +8,17 @@
8
8
 
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { createRequire } from 'node:module';
11
13
 
12
14
  import { writeLockfile, DEFAULT_SOURCE } from '../lockfile.mjs';
13
15
  import { resolveRange } from '../range.mjs';
14
16
  import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
15
17
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
18
+ import { injectClaudeMd } from '../claudeMd.mjs';
19
+
20
+ const _require = createRequire(import.meta.url);
21
+ const CLI_VERSION = _require('../../package.json').version;
16
22
 
17
23
  const DEFAULT_RANGE = '^1';
18
24
  const ALL_TARGETS = ['claude', 'cursor'];
@@ -114,13 +120,28 @@ export async function runInit(consumerCwd, opts = {}) {
114
120
  plannedWrites.push({ relKey, absPath, content, isNew });
115
121
  }
116
122
 
123
+ // Extract auto-route content for CLAUDE.md injection (claude target only).
124
+ // We read from the normalised map so the content is available regardless of
125
+ // whether the `skills` dir ends up in the consumer's scoped file set.
126
+ const claudeTargetActive = resolvedTargets.includes('claude');
127
+ const routingContent = claudeTargetActive
128
+ ? (normalized['claude/skills/auto-route.md'] ?? null)
129
+ : null;
130
+ const claudeMdPath = path.join(consumerCwd, 'CLAUDE.md');
131
+
117
132
  if (dryRun) {
133
+ // Run inject in dry-run mode to compute the planned change.
134
+ const claudeMdResult = routingContent
135
+ ? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
136
+ : null;
137
+
118
138
  return {
119
139
  dryRun: true,
120
140
  resolvedVersion,
121
141
  targets: resolvedTargets,
122
142
  plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
123
143
  files: plannedWrites.map(({ relKey }) => relKey),
144
+ claudeMd: claudeMdResult,
124
145
  };
125
146
  }
126
147
 
@@ -153,6 +174,11 @@ export async function runInit(consumerCwd, opts = {}) {
153
174
  }
154
175
  }
155
176
 
177
+ // Inject or update the auto-route fenced block in CLAUDE.md.
178
+ if (routingContent) {
179
+ injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
180
+ }
181
+
156
182
  writeLockfile(consumerCwd, {
157
183
  source,
158
184
  range,
@@ -6,11 +6,17 @@
6
6
  */
7
7
 
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
9
11
 
10
12
  import { readLockfile, writeLockfile } from '../lockfile.mjs';
11
13
  import { resolveRange } from '../range.mjs';
12
14
  import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
13
15
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
16
+ import { injectClaudeMd } from '../claudeMd.mjs';
17
+
18
+ const _require = createRequire(import.meta.url);
19
+ const CLI_VERSION = _require('../../package.json').version;
14
20
 
15
21
  function filterFileMapByTargets(fileMap, targets) {
16
22
  const out = {};
@@ -61,12 +67,25 @@ export async function runUpdate(consumerCwd, opts = {}) {
61
67
  plannedWrites.push({ relKey, absPath, content, isNew });
62
68
  }
63
69
 
70
+ // Auto-route content for CLAUDE.md injection (claude target only).
71
+ const claudeTargetActive = lock.targets.includes('claude');
72
+ const routingContent = claudeTargetActive
73
+ ? (normalized['claude/skills/auto-route.md'] ?? null)
74
+ : null;
75
+ const claudeMdPath = path.join(consumerCwd, 'CLAUDE.md');
76
+
64
77
  if (dryRun) {
78
+ // Compute the planned CLAUDE.md change so callers can inspect it without
79
+ // the result shape silently diverging from the init --dry-run contract.
80
+ const claudeMdResult = routingContent
81
+ ? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
82
+ : null;
65
83
  return {
66
84
  dryRun: true,
67
85
  resolvedVersion: targetVersion,
68
86
  previousVersion: lock.resolvedVersion,
69
87
  files: plannedWrites.map(({ relKey }) => relKey),
88
+ claudeMd: claudeMdResult,
70
89
  };
71
90
  }
72
91
 
@@ -98,6 +117,11 @@ export async function runUpdate(consumerCwd, opts = {}) {
98
117
  }
99
118
  }
100
119
 
120
+ // Inject or update the auto-route fenced block in CLAUDE.md.
121
+ if (routingContent) {
122
+ injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
123
+ }
124
+
101
125
  writeLockfile(consumerCwd, {
102
126
  source: lock.source,
103
127
  range: lock.range,