@dalzoubi/dev-agents-sync 2.0.16 → 2.0.18
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 +39 -0
- package/package.json +1 -1
- package/src/cli.mjs +1 -0
- package/src/commands/init.mjs +34 -0
- package/src/commands/update.mjs +63 -2
- package/src/hooks.mjs +47 -0
- package/src/lockfile.mjs +20 -0
- package/src/settings.mjs +268 -0
- package/src/steering.mjs +117 -0
- package/src/writer.mjs +19 -1
- package/tests/e2e/steering-hooks-flow.test.mjs +654 -0
- package/tests/hooks-cli-install.test.mjs +1666 -0
- package/tests/steering-agent-behavior.test.mjs +491 -0
- package/tests/steering-seed.test.mjs +645 -0
package/README.md
CHANGED
|
@@ -99,6 +99,44 @@ Show lockfile status:
|
|
|
99
99
|
npx --yes @dalzoubi/dev-agents-sync@1 status
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
## Steering and hooks
|
|
103
|
+
|
|
104
|
+
For the `claude` target, `init` and `update` set up two extra pieces of project memory and automation.
|
|
105
|
+
|
|
106
|
+
### Steering scaffold (consumer-owned)
|
|
107
|
+
|
|
108
|
+
The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions.md`, `glossary.md`, and `architecture.md` — on first run. These files are **consumer-owned**: they carry no managed-by marker, are never tracked in the lockfile, and are **never overwritten** once they exist. Agents read all three at session start and append to them; the toolkit only seeds the blank files once. Editing them is expected.
|
|
109
|
+
|
|
110
|
+
### Hook scripts (managed)
|
|
111
|
+
|
|
112
|
+
`init` and `update` install three hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
|
|
113
|
+
|
|
114
|
+
| Script | Claude Code event | Purpose |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
|
|
117
|
+
| `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
|
|
118
|
+
| `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
|
|
119
|
+
|
|
120
|
+
Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX.Y.Z`. Like other managed files, the CLI overwrites them on `update` and `check` reports drift (exit 1) if one is deleted or locally modified.
|
|
121
|
+
|
|
122
|
+
### settings.json auto-wiring
|
|
123
|
+
|
|
124
|
+
By default, `init` and `update` also register the three hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
|
|
125
|
+
|
|
126
|
+
- **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
|
|
127
|
+
- **Idempotent** — running `update` twice produces no duplicate entries.
|
|
128
|
+
- **Tagged** — each injected entry is marked as CLI-managed so it can be identified and removed later without disturbing consumer-authored hooks.
|
|
129
|
+
|
|
130
|
+
> **Carve-out.** `settings.json` is a consumer-owned configuration file, **not** a marker-managed file. The CLI never replaces it wholesale; it only merges in its own tagged hook registrations and otherwise leaves the file alone. If `settings.json` contains malformed JSON, `update` fails loud with the stable code `ESETTINGS_PARSE` and leaves the file byte-identical — it will never half-write or clobber an unparseable file.
|
|
131
|
+
|
|
132
|
+
To install the hook scripts but skip the `settings.json` merge entirely, pass `--no-hooks-wiring`:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx --yes @dalzoubi/dev-agents-sync@1 update --no-hooks-wiring
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
With `--no-hooks-wiring`, the hook scripts are still installed under `.claude/hooks/`, but `settings.json` is neither created nor modified — wire the hooks yourself if you want them active.
|
|
139
|
+
|
|
102
140
|
## Agents
|
|
103
141
|
|
|
104
142
|
Syncing installs six agents that form a spec-driven workflow:
|
|
@@ -137,6 +175,7 @@ and build if relevant) and summarizes what changed. It does not push or open a P
|
|
|
137
175
|
- `--range ^1` selects the content version range. The default is `^1`.
|
|
138
176
|
- `--dry-run` prints the planned changes without writing files.
|
|
139
177
|
- `--force` allows overwriting unmanaged file collisions.
|
|
178
|
+
- `--no-hooks-wiring` installs the hook scripts but skips the `.claude/settings.json` merge — see [Steering and hooks](#steering-and-hooks).
|
|
140
179
|
- `--token <token>` passes a GitHub token directly.
|
|
141
180
|
|
|
142
181
|
## Safety
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -16,6 +16,9 @@ import { resolveRange } from '../range.mjs';
|
|
|
16
16
|
import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
|
|
17
17
|
import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
|
|
18
18
|
import { injectClaudeMd } from '../claudeMd.mjs';
|
|
19
|
+
import { seedSteering, steeringSummary } from '../steering.mjs';
|
|
20
|
+
import { mergeHookSettings } from '../settings.mjs';
|
|
21
|
+
import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
|
|
19
22
|
|
|
20
23
|
const _require = createRequire(import.meta.url);
|
|
21
24
|
const CLI_VERSION = _require('../../package.json').version;
|
|
@@ -84,6 +87,7 @@ export async function runInit(consumerCwd, opts = {}) {
|
|
|
84
87
|
token,
|
|
85
88
|
fetcher,
|
|
86
89
|
availableTags,
|
|
90
|
+
noHooksWiring = false,
|
|
87
91
|
source = DEFAULT_SOURCE,
|
|
88
92
|
repo = 'dalzoubi/dev-agents',
|
|
89
93
|
} = opts;
|
|
@@ -135,6 +139,11 @@ export async function runInit(consumerCwd, opts = {}) {
|
|
|
135
139
|
? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
|
|
136
140
|
: null;
|
|
137
141
|
|
|
142
|
+
// Steering seed plan (claude target only). seed-if-absent; writes nothing in dry-run.
|
|
143
|
+
const steering = claudeTargetActive
|
|
144
|
+
? seedSteering(consumerCwd, { dryRun: true })
|
|
145
|
+
: null;
|
|
146
|
+
|
|
138
147
|
return {
|
|
139
148
|
dryRun: true,
|
|
140
149
|
resolvedVersion,
|
|
@@ -142,6 +151,7 @@ export async function runInit(consumerCwd, opts = {}) {
|
|
|
142
151
|
plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
|
|
143
152
|
files: plannedWrites.map(({ relKey }) => relKey),
|
|
144
153
|
claudeMd: claudeMdResult,
|
|
154
|
+
steering,
|
|
145
155
|
};
|
|
146
156
|
}
|
|
147
157
|
|
|
@@ -179,11 +189,33 @@ export async function runInit(consumerCwd, opts = {}) {
|
|
|
179
189
|
injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
// Seed the consumer-owned steering scaffold (claude target only). Seed-if-absent:
|
|
193
|
+
// never overwrites edited steering, never recorded in the lockfile (it is unmanaged
|
|
194
|
+
// by design). Fails loud with ESTEER_WRITE on a write error.
|
|
195
|
+
let steering = null;
|
|
196
|
+
if (claudeTargetActive) {
|
|
197
|
+
steering = seedSteering(consumerCwd);
|
|
198
|
+
process.stderr.write(`info: dev-agents-sync: ${steeringSummary(steering)}\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Wire the installed hook scripts into .claude/settings.json (claude target
|
|
202
|
+
// only), unless the consumer opted out with --no-hooks-wiring. The merge is
|
|
203
|
+
// additive + idempotent and fails loud (ESETTINGS_PARSE) on a malformed file.
|
|
204
|
+
const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
|
|
205
|
+
const hookPaths = collectHookPaths(installedHookKeys);
|
|
206
|
+
if (claudeTargetActive && !noHooksWiring) {
|
|
207
|
+
const hookSpec = hookScriptsForInstalled(installedHookKeys);
|
|
208
|
+
if (hookSpec.length > 0) {
|
|
209
|
+
mergeHookSettings(consumerCwd, hookSpec);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
182
213
|
writeLockfile(consumerCwd, {
|
|
183
214
|
source,
|
|
184
215
|
range,
|
|
185
216
|
resolvedVersion,
|
|
186
217
|
targets: [...resolvedTargets].sort(),
|
|
218
|
+
...(hookPaths.length > 0 ? { hooks: hookPaths } : {}),
|
|
187
219
|
lastUpdated: new Date().toISOString(),
|
|
188
220
|
});
|
|
189
221
|
|
|
@@ -192,5 +224,7 @@ export async function runInit(consumerCwd, opts = {}) {
|
|
|
192
224
|
targets: [...resolvedTargets].sort(),
|
|
193
225
|
plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
|
|
194
226
|
files: plannedWrites.map(({ relKey }) => relKey),
|
|
227
|
+
hooks: hookPaths,
|
|
228
|
+
steering,
|
|
195
229
|
};
|
|
196
230
|
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -14,6 +14,9 @@ import { resolveRange } from '../range.mjs';
|
|
|
14
14
|
import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
|
|
15
15
|
import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
|
|
16
16
|
import { injectClaudeMd } from '../claudeMd.mjs';
|
|
17
|
+
import { seedSteering, steeringSummary } from '../steering.mjs';
|
|
18
|
+
import { mergeHookSettings, DEFAULT_HOOK_SCRIPTS } from '../settings.mjs';
|
|
19
|
+
import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
|
|
17
20
|
|
|
18
21
|
const _require = createRequire(import.meta.url);
|
|
19
22
|
const CLI_VERSION = _require('../../package.json').version;
|
|
@@ -31,7 +34,15 @@ function filterFileMapByTargets(fileMap, targets) {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export async function runUpdate(consumerCwd, opts = {}) {
|
|
34
|
-
const {
|
|
37
|
+
const {
|
|
38
|
+
force = false,
|
|
39
|
+
dryRun = false,
|
|
40
|
+
token,
|
|
41
|
+
fetcher,
|
|
42
|
+
availableTags,
|
|
43
|
+
noHooksWiring = false,
|
|
44
|
+
repo = 'dalzoubi/dev-agents',
|
|
45
|
+
} = opts;
|
|
35
46
|
|
|
36
47
|
if (typeof fetcher !== 'function') {
|
|
37
48
|
const err = new Error('runUpdate requires an injected fetcher');
|
|
@@ -48,11 +59,40 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
48
59
|
|
|
49
60
|
const targetVersion = resolveRange(availableTags, lock.range);
|
|
50
61
|
|
|
62
|
+
// Seed the consumer-owned steering scaffold (claude target only), seed-if-absent,
|
|
63
|
+
// BEFORE the up-to-date short-circuit so existing consumers adopt steering on any
|
|
64
|
+
// `update` — not only when a version bump is available. Never overwrites edited
|
|
65
|
+
// steering; never recorded in the lockfile (unmanaged by design); respects --dry-run.
|
|
66
|
+
const claudeTargetActive = lock.targets.includes('claude');
|
|
67
|
+
let steering = null;
|
|
68
|
+
if (claudeTargetActive) {
|
|
69
|
+
steering = seedSteering(consumerCwd, { dryRun });
|
|
70
|
+
if (!dryRun) {
|
|
71
|
+
process.stderr.write(`info: dev-agents-sync: ${steeringSummary(steering)}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
if (targetVersion === lock.resolvedVersion) {
|
|
76
|
+
// Up to date — but mirror the STEERING precedent above: a consumer already
|
|
77
|
+
// pinned to the latest version who never had hooks wired must still get the
|
|
78
|
+
// settings merge so adoption isn't gated on a version bump. We do NOT fetch
|
|
79
|
+
// here (the short-circuit's whole point is to avoid a fetch); instead we wire
|
|
80
|
+
// only the hook scripts that already exist on disk. The merge is additive +
|
|
81
|
+
// idempotent, so running twice == once. --dry-run still writes nothing.
|
|
82
|
+
if (claudeTargetActive && !noHooksWiring && !dryRun) {
|
|
83
|
+
const hookSpec = DEFAULT_HOOK_SCRIPTS.filter((h) =>
|
|
84
|
+
existsSync(path.join(consumerCwd, h.command)),
|
|
85
|
+
);
|
|
86
|
+
if (hookSpec.length > 0) {
|
|
87
|
+
mergeHookSettings(consumerCwd, hookSpec);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
52
91
|
return {
|
|
53
92
|
upToDate: true,
|
|
54
93
|
resolvedVersion: lock.resolvedVersion,
|
|
55
94
|
message: `already up to date at v${lock.resolvedVersion}`,
|
|
95
|
+
steering,
|
|
56
96
|
};
|
|
57
97
|
}
|
|
58
98
|
|
|
@@ -68,7 +108,6 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
// Auto-route content for CLAUDE.md injection (claude target only).
|
|
71
|
-
const claudeTargetActive = lock.targets.includes('claude');
|
|
72
111
|
const routingContent = claudeTargetActive
|
|
73
112
|
? (normalized['claude/skills/auto-route.md'] ?? null)
|
|
74
113
|
: null;
|
|
@@ -86,6 +125,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
86
125
|
previousVersion: lock.resolvedVersion,
|
|
87
126
|
files: plannedWrites.map(({ relKey }) => relKey),
|
|
88
127
|
claudeMd: claudeMdResult,
|
|
128
|
+
steering,
|
|
89
129
|
};
|
|
90
130
|
}
|
|
91
131
|
|
|
@@ -122,11 +162,30 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
122
162
|
injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
|
|
123
163
|
}
|
|
124
164
|
|
|
165
|
+
// Wire installed hook scripts into .claude/settings.json (claude target only),
|
|
166
|
+
// unless --no-hooks-wiring. Additive + idempotent; fails loud (ESETTINGS_PARSE)
|
|
167
|
+
// on a malformed settings.json, leaving it byte-identical.
|
|
168
|
+
const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
|
|
169
|
+
const hookPaths = collectHookPaths(installedHookKeys);
|
|
170
|
+
if (claudeTargetActive && !noHooksWiring) {
|
|
171
|
+
const hookSpec = hookScriptsForInstalled(installedHookKeys);
|
|
172
|
+
if (hookSpec.length > 0) {
|
|
173
|
+
mergeHookSettings(consumerCwd, hookSpec);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
125
177
|
writeLockfile(consumerCwd, {
|
|
126
178
|
source: lock.source,
|
|
127
179
|
range: lock.range,
|
|
128
180
|
resolvedVersion: targetVersion,
|
|
129
181
|
targets: lock.targets,
|
|
182
|
+
// Carry forward installed hooks (additive §8). Falls back to any hooks the
|
|
183
|
+
// pre-existing lockfile already recorded when this update shipped none.
|
|
184
|
+
...(hookPaths.length > 0
|
|
185
|
+
? { hooks: hookPaths }
|
|
186
|
+
: Array.isArray(lock.hooks)
|
|
187
|
+
? { hooks: lock.hooks }
|
|
188
|
+
: {}),
|
|
130
189
|
lastUpdated: new Date().toISOString(),
|
|
131
190
|
});
|
|
132
191
|
|
|
@@ -134,5 +193,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
134
193
|
resolvedVersion: targetVersion,
|
|
135
194
|
previousVersion: lock.resolvedVersion,
|
|
136
195
|
files: plannedWrites.map(({ relKey }) => relKey),
|
|
196
|
+
hooks: hookPaths,
|
|
197
|
+
steering,
|
|
137
198
|
};
|
|
138
199
|
}
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook install helpers shared by `init` and `update`.
|
|
3
|
+
*
|
|
4
|
+
* Hook scripts ride the normal managed-file install path (they arrive in the
|
|
5
|
+
* fetched FileMap under `claude/hooks/*.sh` and are written by writeManagedFile,
|
|
6
|
+
* which also sets the exec bit). These helpers derive, from the set of files
|
|
7
|
+
* that were actually planned/installed, (a) the consumer-relative hook paths to
|
|
8
|
+
* record in the lockfile and (b) the settings.json registration spec — so the
|
|
9
|
+
* CLI only wires hooks that genuinely shipped in the resolved version.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEFAULT_HOOK_SCRIPTS } from './settings.mjs';
|
|
13
|
+
|
|
14
|
+
/** True for a FileMap key that is a managed hook script (claude/hooks/*.sh). */
|
|
15
|
+
function isHookKey(relKey) {
|
|
16
|
+
const norm = relKey.replace(/\\/g, '/');
|
|
17
|
+
return /^claude\/hooks\/.+\.sh$/.test(norm);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maps a `claude/hooks/foo.sh` FileMap key to its consumer-relative path
|
|
22
|
+
* `.claude/hooks/foo.sh` (the form recorded in the lockfile and used as the
|
|
23
|
+
* settings.json command).
|
|
24
|
+
*/
|
|
25
|
+
function toConsumerHookPath(relKey) {
|
|
26
|
+
const norm = relKey.replace(/\\/g, '/');
|
|
27
|
+
return norm.replace(/^claude\//, '.claude/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns the sorted list of consumer-relative hook script paths present in the
|
|
32
|
+
* given FileMap keys. Used to record installed hooks in the lockfile.
|
|
33
|
+
*/
|
|
34
|
+
export function collectHookPaths(relKeys) {
|
|
35
|
+
const paths = relKeys.filter(isHookKey).map(toConsumerHookPath);
|
|
36
|
+
return [...new Set(paths)].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the settings.json hook registration spec restricted to the hook
|
|
41
|
+
* scripts that were actually installed (present in `relKeys`). Each registration
|
|
42
|
+
* is taken from DEFAULT_HOOK_SCRIPTS so the event/matcher stay authoritative.
|
|
43
|
+
*/
|
|
44
|
+
export function hookScriptsForInstalled(relKeys) {
|
|
45
|
+
const installed = new Set(collectHookPaths(relKeys));
|
|
46
|
+
return DEFAULT_HOOK_SCRIPTS.filter((h) => installed.has(h.command));
|
|
47
|
+
}
|
package/src/lockfile.mjs
CHANGED
|
@@ -19,12 +19,18 @@ export const SCHEMA_URL =
|
|
|
19
19
|
const VALID_TARGETS = new Set(['claude', 'cursor', 'copilot', 'codex']);
|
|
20
20
|
|
|
21
21
|
// Field order is load-bearing for determinism.
|
|
22
|
+
//
|
|
23
|
+
// `hooks` is an ADDITIVE, OPTIONAL field (§8): it records the relative paths of
|
|
24
|
+
// the managed hook scripts installed under .claude/hooks/. Pre-hooks lockfiles
|
|
25
|
+
// omit it entirely and still parse/validate — it is never a required field, so
|
|
26
|
+
// the upgrade is non-breaking and needs no migration prompt.
|
|
22
27
|
const FIELD_ORDER = [
|
|
23
28
|
'$schema',
|
|
24
29
|
'source',
|
|
25
30
|
'range',
|
|
26
31
|
'resolvedVersion',
|
|
27
32
|
'targets',
|
|
33
|
+
'hooks',
|
|
28
34
|
'lastUpdated',
|
|
29
35
|
];
|
|
30
36
|
|
|
@@ -71,6 +77,13 @@ export function validateLockfile(lock) {
|
|
|
71
77
|
);
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
// `hooks` is optional (additive §8). When present it must be a string array;
|
|
81
|
+
// its absence is valid and must never be treated as an error (back-compat).
|
|
82
|
+
if (lock.hooks !== undefined) {
|
|
83
|
+
if (!Array.isArray(lock.hooks) || lock.hooks.some((h) => typeof h !== 'string')) {
|
|
84
|
+
throw new LockfileError('invalid lockfile: `hooks` must be an array of strings when present');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
74
87
|
return true;
|
|
75
88
|
}
|
|
76
89
|
|
|
@@ -89,6 +102,11 @@ export function serializeLockfile(lock) {
|
|
|
89
102
|
if (lock[key] === undefined) continue;
|
|
90
103
|
if (key === 'targets') {
|
|
91
104
|
out.targets = [...lock.targets].sort();
|
|
105
|
+
} else if (key === 'hooks') {
|
|
106
|
+
// Optional additive field; only emit when present. Sorted for determinism.
|
|
107
|
+
if (Array.isArray(lock.hooks)) {
|
|
108
|
+
out.hooks = [...lock.hooks].sort();
|
|
109
|
+
}
|
|
92
110
|
} else {
|
|
93
111
|
out[key] = lock[key];
|
|
94
112
|
}
|
|
@@ -108,6 +126,8 @@ export function writeLockfile(consumerCwd, lock, _options) {
|
|
|
108
126
|
range: lock.range,
|
|
109
127
|
resolvedVersion: lock.resolvedVersion,
|
|
110
128
|
targets: lock.targets,
|
|
129
|
+
// Additive optional field — only carried forward when supplied.
|
|
130
|
+
...(Array.isArray(lock.hooks) ? { hooks: lock.hooks } : {}),
|
|
111
131
|
lastUpdated: lock.lastUpdated,
|
|
112
132
|
};
|
|
113
133
|
validateLockfile(normalized);
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude `settings.json` hook auto-wiring.
|
|
3
|
+
*
|
|
4
|
+
* `init`/`update` install the three managed hook scripts into `.claude/hooks/`
|
|
5
|
+
* via the normal managed-file path. This module performs the genuinely-new step:
|
|
6
|
+
* an ADDITIVE, IDEMPOTENT merge of the corresponding hook registrations into the
|
|
7
|
+
* consumer's `.claude/settings.json`.
|
|
8
|
+
*
|
|
9
|
+
* Invariants enforced here:
|
|
10
|
+
* - Additive: every existing setting and every consumer-authored hook is
|
|
11
|
+
* preserved untouched. We only append our own registrations.
|
|
12
|
+
* - Idempotent (§9): a second merge adds no duplicates. Identity is the
|
|
13
|
+
* installed command path, so re-running is a no-op.
|
|
14
|
+
* - Tagged (§4): each injected hook entry carries `HOOK_TAG: true` so it is
|
|
15
|
+
* identifiable and removable later (update/uninstall) without touching
|
|
16
|
+
* consumer-authored entries. Consumer entries never receive the tag.
|
|
17
|
+
* - Fail loud (§5/§12): a malformed `settings.json` surfaces a stable
|
|
18
|
+
* `ESETTINGS_PARSE` code and the file is left byte-identical (no partial
|
|
19
|
+
* write). We never half-write.
|
|
20
|
+
* - Deterministic (§9): stable 2-space JSON + trailing newline, no timestamps.
|
|
21
|
+
* - --dry-run writes nothing.
|
|
22
|
+
* - Privacy (§11): no tokens are ever read or written here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Property injected onto every CLI-managed hook entry so it can be identified
|
|
30
|
+
* and removed without disturbing consumer-authored entries. The value is the
|
|
31
|
+
* literal property NAME (the tests look for it in the serialized settings and
|
|
32
|
+
* assert it is absent on consumer entries).
|
|
33
|
+
*/
|
|
34
|
+
export const HOOK_TAG = 'managedBy:dev-agents-sync';
|
|
35
|
+
|
|
36
|
+
export class SettingsParseError extends Error {
|
|
37
|
+
constructor(message, { cause } = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'SettingsParseError';
|
|
40
|
+
this.code = 'ESETTINGS_PARSE';
|
|
41
|
+
this.exitCode = 1;
|
|
42
|
+
if (cause) this.cause = cause;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function settingsPathFor(consumerCwd) {
|
|
47
|
+
return path.join(consumerCwd, '.claude', 'settings.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True if `value` is a plain (non-array, non-null) object. */
|
|
51
|
+
function isPlainObject(value) {
|
|
52
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fail-loud validation of the INTERNAL shape of a settings `hooks` field before
|
|
57
|
+
* any merge or removal touches it (§5/§12). `readSettings` already guarantees
|
|
58
|
+
* the top-level value is a plain object; this guards the sub-tree so a
|
|
59
|
+
* wrong-shape `hooks` (e.g. a string or array) is never silently spread into an
|
|
60
|
+
* index-keyed object, and a wrong-shape event value (e.g. a non-array) is never
|
|
61
|
+
* silently discarded. On any violation we throw SettingsParseError so NO write
|
|
62
|
+
* happens and the consumer file is left byte-identical.
|
|
63
|
+
*
|
|
64
|
+
* Accepts an absent `hooks` (undefined). Otherwise `hooks` must be a plain
|
|
65
|
+
* object and every `hooks[event]` must be an array (or absent/undefined).
|
|
66
|
+
*/
|
|
67
|
+
function assertHooksShape(hooks, absPath) {
|
|
68
|
+
if (hooks === undefined) return;
|
|
69
|
+
if (!isPlainObject(hooks)) {
|
|
70
|
+
throw new SettingsParseError(
|
|
71
|
+
`ESETTINGS_PARSE: "hooks" in ${absPath} must be a JSON object — fix or remove it, then re-run`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
75
|
+
if (entries === undefined) continue;
|
|
76
|
+
if (!Array.isArray(entries)) {
|
|
77
|
+
throw new SettingsParseError(
|
|
78
|
+
`ESETTINGS_PARSE: "hooks.${event}" in ${absPath} must be a JSON array — fix or remove it, then re-run`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reads and parses the consumer's settings.json.
|
|
86
|
+
* Returns {} when the file is absent. Throws SettingsParseError on malformed JSON.
|
|
87
|
+
*/
|
|
88
|
+
function readSettings(absPath) {
|
|
89
|
+
if (!existsSync(absPath)) return {};
|
|
90
|
+
const raw = readFileSync(absPath, 'utf8');
|
|
91
|
+
if (raw.trim().length === 0) return {};
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
95
|
+
throw new SettingsParseError(
|
|
96
|
+
`ESETTINGS_PARSE: ${absPath} is not a JSON object — fix or remove it, then re-run`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof SettingsParseError) throw err;
|
|
102
|
+
throw new SettingsParseError(
|
|
103
|
+
`ESETTINGS_PARSE: could not parse ${absPath} as JSON — fix the file or remove it, then re-run`,
|
|
104
|
+
{ cause: err },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True if `event` already has a CLI-managed entry whose command targets `command`. */
|
|
110
|
+
function hasManagedCommand(eventEntries, command) {
|
|
111
|
+
if (!Array.isArray(eventEntries)) return false;
|
|
112
|
+
for (const entry of eventEntries) {
|
|
113
|
+
if (!entry || entry[HOOK_TAG] !== true) continue;
|
|
114
|
+
const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
115
|
+
for (const h of hookList) {
|
|
116
|
+
if (h && typeof h.command === 'string' && h.command === command) return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds the managed hook entry for one registration. The HOOK_TAG property
|
|
124
|
+
* marks it as CLI-owned.
|
|
125
|
+
*/
|
|
126
|
+
function buildManagedEntry({ matcher, command }) {
|
|
127
|
+
return {
|
|
128
|
+
[HOOK_TAG]: true,
|
|
129
|
+
matcher,
|
|
130
|
+
hooks: [{ type: 'command', command }],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Computes the merged settings object additively. Pure — does not touch disk.
|
|
136
|
+
* Returns { settings, changed }. Fails loud (ESETTINGS_PARSE) if the consumer's
|
|
137
|
+
* `hooks` sub-tree has the wrong internal shape, BEFORE any merge — so no write
|
|
138
|
+
* happens and the file stays byte-identical.
|
|
139
|
+
*/
|
|
140
|
+
function computeMerge(current, hookScripts, absPath) {
|
|
141
|
+
// Validate the internal shape of the consumer's hooks before merging so a
|
|
142
|
+
// wrong-shape value is never silently corrupted or discarded (§5/§12).
|
|
143
|
+
assertHooksShape(current.hooks, absPath);
|
|
144
|
+
|
|
145
|
+
// Shallow clone so we never mutate the caller's object; hooks is the only
|
|
146
|
+
// sub-tree we touch and we clone it per-event below before appending.
|
|
147
|
+
const next = { ...current };
|
|
148
|
+
const hooks = { ...(next.hooks ?? {}) };
|
|
149
|
+
let changed = false;
|
|
150
|
+
|
|
151
|
+
for (const { event, matcher, command } of hookScripts) {
|
|
152
|
+
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
153
|
+
if (hasManagedCommand(existing, command)) {
|
|
154
|
+
// Already wired — idempotent no-op for this registration.
|
|
155
|
+
hooks[event] = existing;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
hooks[event] = [...existing, buildManagedEntry({ matcher, command })];
|
|
159
|
+
changed = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (changed) {
|
|
163
|
+
next.hooks = hooks;
|
|
164
|
+
}
|
|
165
|
+
return { settings: next, changed };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Idempotent, additive merge of the given hook registrations into the
|
|
170
|
+
* consumer's `.claude/settings.json`.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} consumerCwd
|
|
173
|
+
* @param {{ event: string, matcher: string, command: string }[]} hookScripts
|
|
174
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
175
|
+
* @returns {{ merged: boolean, dryRun: boolean, settingsPath: string }}
|
|
176
|
+
* @throws {SettingsParseError} with .code === 'ESETTINGS_PARSE' on malformed settings.json
|
|
177
|
+
*/
|
|
178
|
+
export function mergeHookSettings(consumerCwd, hookScripts, opts = {}) {
|
|
179
|
+
const { dryRun = false } = opts;
|
|
180
|
+
const absPath = settingsPathFor(consumerCwd);
|
|
181
|
+
|
|
182
|
+
// Parse first — a malformed file fails loud BEFORE any write so the consumer's
|
|
183
|
+
// file is never clobbered (§5/§12).
|
|
184
|
+
const current = readSettings(absPath);
|
|
185
|
+
const { settings, changed } = computeMerge(current, hookScripts, absPath);
|
|
186
|
+
|
|
187
|
+
if (dryRun) {
|
|
188
|
+
return { merged: changed, dryRun: true, settingsPath: absPath };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!changed) {
|
|
192
|
+
// Nothing to do — leave the file byte-identical (idempotent §9).
|
|
193
|
+
return { merged: false, dryRun: false, settingsPath: absPath };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
mkdirSync(path.dirname(absPath), { recursive: true });
|
|
197
|
+
writeFileSync(absPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
198
|
+
return { merged: true, dryRun: false, settingsPath: absPath };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Removes all CLI-managed hook entries (those carrying HOOK_TAG) from the
|
|
203
|
+
* consumer's settings.json, leaving consumer-authored entries untouched. Used by
|
|
204
|
+
* uninstall flows. Returns { removed, dryRun } and writes nothing in dry-run.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} consumerCwd
|
|
207
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
208
|
+
*/
|
|
209
|
+
export function removeHookSettings(consumerCwd, opts = {}) {
|
|
210
|
+
const { dryRun = false } = opts;
|
|
211
|
+
const absPath = settingsPathFor(consumerCwd);
|
|
212
|
+
|
|
213
|
+
if (!existsSync(absPath)) {
|
|
214
|
+
return { removed: false, dryRun, settingsPath: absPath };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const current = readSettings(absPath);
|
|
218
|
+
// Same fail-loud posture as the merge path: a wrong-shape `hooks` sub-tree
|
|
219
|
+
// surfaces ESETTINGS_PARSE and leaves the file byte-identical (§5/§12) rather
|
|
220
|
+
// than crashing or silently mangling it.
|
|
221
|
+
assertHooksShape(current.hooks, absPath);
|
|
222
|
+
const hooks = current.hooks ?? {};
|
|
223
|
+
let changed = false;
|
|
224
|
+
const nextHooks = {};
|
|
225
|
+
|
|
226
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
227
|
+
if (!Array.isArray(entries)) {
|
|
228
|
+
nextHooks[event] = entries;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const kept = entries.filter((entry) => !(entry && entry[HOOK_TAG] === true));
|
|
232
|
+
if (kept.length !== entries.length) changed = true;
|
|
233
|
+
if (kept.length > 0) {
|
|
234
|
+
nextHooks[event] = kept;
|
|
235
|
+
} else {
|
|
236
|
+
// Drop the now-empty event bucket so we don't leave dangling keys.
|
|
237
|
+
changed = changed || entries.length > 0;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!changed) {
|
|
242
|
+
return { removed: false, dryRun, settingsPath: absPath };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const next = { ...current };
|
|
246
|
+
if (Object.keys(nextHooks).length > 0) {
|
|
247
|
+
next.hooks = nextHooks;
|
|
248
|
+
} else {
|
|
249
|
+
delete next.hooks;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (dryRun) {
|
|
253
|
+
return { removed: true, dryRun: true, settingsPath: absPath };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
writeFileSync(absPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
257
|
+
return { removed: true, dryRun: false, settingsPath: absPath };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The default hook registration spec — maps each installed hook script to its
|
|
262
|
+
* Claude Code event. Kept here so init/update share one source of truth.
|
|
263
|
+
*/
|
|
264
|
+
export const DEFAULT_HOOK_SCRIPTS = [
|
|
265
|
+
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
266
|
+
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
267
|
+
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
268
|
+
];
|